Fraud Detection Patterns in Lending Systems

An exploration of fraud detection techniques for lending platforms, covering application fraud, identity fraud, synthetic identity detection, velocity checks, and anomaly detection patterns in TypeScript.

technical9 min readBy Klivvr Engineering
Share:

Fraud is an ever-present threat in digital lending. The same convenience that makes online loan applications attractive to legitimate borrowers---speed, accessibility, minimal physical documentation---also makes them attractive to fraudsters. Application fraud, identity theft, synthetic identity fraud, and income misrepresentation cost the lending industry billions annually. A robust fraud detection system must operate in real time without introducing unacceptable friction for legitimate applicants.

This article examines the most common fraud patterns in lending and demonstrates how to implement detection mechanisms in TypeScript, covering rule-based checks, velocity monitoring, device fingerprinting, network analysis, and anomaly scoring.

Taxonomy of Lending Fraud

Before building defenses, it is essential to understand what the platform is defending against. Lending fraud falls into several categories, each requiring different detection approaches.

First-party fraud occurs when the borrower misrepresents their own information---inflating income, hiding existing debts, or providing false employment details. Third-party fraud involves using stolen identity information to apply for a loan in someone else's name. Synthetic identity fraud, perhaps the most insidious form, involves creating a fictitious identity by combining real and fabricated data elements, such as a real Social Security number with a fake name and address.

type FraudType =
  | "first_party_income"
  | "first_party_employment"
  | "first_party_asset"
  | "third_party_identity_theft"
  | "synthetic_identity"
  | "application_manipulation"
  | "collusion"
  | "bust_out";
 
interface FraudSignal {
  signalType: FraudType;
  confidence: number; // 0 to 1
  description: string;
  detectedBy: string;
  rawEvidence: Record<string, unknown>;
}
 
interface FraudAssessment {
  applicationId: string;
  overallRiskScore: number; // 0 to 1000
  riskLevel: "low" | "medium" | "high" | "critical";
  signals: FraudSignal[];
  recommendation: "proceed" | "enhanced_review" | "manual_review" | "block";
  assessedAt: Date;
}

Rule-Based Fraud Checks

The first layer of defense consists of deterministic rules that check for known fraud indicators. These rules are fast, explainable, and catch the most common patterns.

interface FraudRule {
  id: string;
  name: string;
  category: FraudType;
  check: (context: FraudCheckContext) => Promise<FraudSignal | null>;
  priority: number;
  isActive: boolean;
}
 
interface FraudCheckContext {
  application: LoanApplication;
  borrowerProfile: BorrowerProfile;
  creditReport?: CreditReport;
  deviceInfo?: DeviceFingerprint;
  previousApplications: LoanApplication[];
  ipAddress: string;
  sessionData: SessionData;
}
 
const fraudRules: FraudRule[] = [
  {
    id: "FR001",
    name: "SSN issued before birth year",
    category: "synthetic_identity",
    priority: 1,
    isActive: true,
    check: async (ctx) => {
      const ssnIssueYear = estimateSSNIssueYear(ctx.borrowerProfile.ssn);
      const birthYear = new Date(ctx.borrowerProfile.dateOfBirth).getFullYear();
 
      if (ssnIssueYear && ssnIssueYear < birthYear - 1) {
        return {
          signalType: "synthetic_identity",
          confidence: 0.85,
          description: "SSN appears to have been issued before applicant's birth year",
          detectedBy: "FR001",
          rawEvidence: { ssnIssueYear, birthYear },
        };
      }
      return null;
    },
  },
  {
    id: "FR002",
    name: "Income inconsistency with credit profile",
    category: "first_party_income",
    priority: 2,
    isActive: true,
    check: async (ctx) => {
      if (!ctx.creditReport) return null;
 
      const statedIncome = ctx.application.statedAnnualIncome;
      const totalCreditLimit = ctx.creditReport.tradelines.reduce(
        (sum, t) => sum + (t.creditLimit ?? 0),
        0
      );
 
      // Credit limits vastly exceeding stated income can indicate
      // either inflated income or an identity with a different credit history
      if (statedIncome > 0 && totalCreditLimit > statedIncome * 5) {
        return {
          signalType: "first_party_income",
          confidence: 0.6,
          description: "Total credit limits significantly exceed stated annual income",
          detectedBy: "FR002",
          rawEvidence: { statedIncome, totalCreditLimit },
        };
      }
      return null;
    },
  },
  {
    id: "FR003",
    name: "Recently established credit file",
    category: "synthetic_identity",
    priority: 3,
    isActive: true,
    check: async (ctx) => {
      if (!ctx.creditReport) return null;
 
      const oldestTradeline = ctx.creditReport.tradelines.reduce(
        (oldest, t) =>
          new Date(t.openDate) < oldest ? new Date(t.openDate) : oldest,
        new Date()
      );
 
      const creditAgeMonths =
        (Date.now() - oldestTradeline.getTime()) / (1000 * 60 * 60 * 24 * 30);
 
      const applicantAge =
        (Date.now() - new Date(ctx.borrowerProfile.dateOfBirth).getTime()) /
        (1000 * 60 * 60 * 24 * 365);
 
      if (applicantAge > 30 && creditAgeMonths < 24) {
        return {
          signalType: "synthetic_identity",
          confidence: 0.7,
          description: "Applicant age inconsistent with thin credit file",
          detectedBy: "FR003",
          rawEvidence: { applicantAge, creditAgeMonths },
        };
      }
      return null;
    },
  },
];

A practical tip: assign confidence scores to each signal rather than making binary fraud/not-fraud decisions at the rule level. This allows downstream systems to weigh multiple weak signals that, combined, form a strong indicator.

Velocity Checks

Velocity checks detect anomalous patterns of activity that suggest automated attacks or fraud rings. They monitor the rate of applications, inquiries, and other events across multiple dimensions.

interface VelocityRule {
  id: string;
  dimension: string; // e.g., "ip_address", "ssn", "email", "phone", "device_id"
  windowMinutes: number;
  maxCount: number;
  action: "flag" | "block" | "challenge";
}
 
class VelocityEngine {
  constructor(private readonly store: VelocityStore) {}
 
  private readonly rules: VelocityRule[] = [
    {
      id: "VR001",
      dimension: "ip_address",
      windowMinutes: 60,
      maxCount: 3,
      action: "flag",
    },
    {
      id: "VR002",
      dimension: "ssn",
      windowMinutes: 1440, // 24 hours
      maxCount: 1,
      action: "block",
    },
    {
      id: "VR003",
      dimension: "device_id",
      windowMinutes: 1440,
      maxCount: 5,
      action: "flag",
    },
    {
      id: "VR004",
      dimension: "email_domain",
      windowMinutes: 60,
      maxCount: 10,
      action: "challenge",
    },
    {
      id: "VR005",
      dimension: "phone_number",
      windowMinutes: 1440,
      maxCount: 2,
      action: "flag",
    },
  ];
 
  async checkVelocity(
    context: FraudCheckContext
  ): Promise<VelocityCheckResult[]> {
    const results: VelocityCheckResult[] = [];
    const dimensionValues = this.extractDimensions(context);
 
    for (const rule of this.rules) {
      const value = dimensionValues[rule.dimension];
      if (!value) continue;
 
      const count = await this.store.getCount(
        rule.dimension,
        value,
        rule.windowMinutes
      );
 
      await this.store.increment(rule.dimension, value, rule.windowMinutes);
 
      if (count >= rule.maxCount) {
        results.push({
          ruleId: rule.id,
          dimension: rule.dimension,
          value,
          count: count + 1,
          maxAllowed: rule.maxCount,
          windowMinutes: rule.windowMinutes,
          action: rule.action,
          triggered: true,
        });
      }
    }
 
    return results;
  }
 
  private extractDimensions(
    context: FraudCheckContext
  ): Record<string, string> {
    return {
      ip_address: context.ipAddress,
      ssn: context.borrowerProfile.ssn,
      email: context.borrowerProfile.email,
      email_domain: context.borrowerProfile.email.split("@")[1],
      phone_number: context.borrowerProfile.phone,
      device_id: context.deviceInfo?.deviceId ?? "",
    };
  }
}
 
interface VelocityCheckResult {
  ruleId: string;
  dimension: string;
  value: string;
  count: number;
  maxAllowed: number;
  windowMinutes: number;
  action: "flag" | "block" | "challenge";
  triggered: boolean;
}

The velocity store is typically backed by Redis or a similar in-memory store with TTL-based expiration. Using sliding windows with atomic increment operations ensures that velocity checks remain accurate under high concurrency.

Device Fingerprinting and Session Analysis

Device fingerprinting collects browser and hardware characteristics to identify devices even across sessions. Anomalies in device data can indicate fraud---for example, a device that claims to be a mobile phone but has desktop-resolution capabilities, or a session that changes geographic location mid-application.

interface DeviceFingerprint {
  deviceId: string;
  userAgent: string;
  screenResolution: string;
  timezone: string;
  language: string;
  platform: string;
  webglRenderer: string;
  canvasHash: string;
  installedFonts: string[];
  pluginCount: number;
  cookiesEnabled: boolean;
  localStorageAvailable: boolean;
  ipGeolocation: GeoLocation;
}
 
interface GeoLocation {
  country: string;
  region: string;
  city: string;
  latitude: number;
  longitude: number;
}
 
class DeviceRiskAnalyzer {
  analyze(
    fingerprint: DeviceFingerprint,
    borrowerProfile: BorrowerProfile,
    historicalFingerprints: DeviceFingerprint[]
  ): FraudSignal[] {
    const signals: FraudSignal[] = [];
 
    // Check for VPN or proxy indicators
    if (this.isLikelyProxy(fingerprint)) {
      signals.push({
        signalType: "application_manipulation",
        confidence: 0.5,
        description: "Device appears to be using a proxy or VPN",
        detectedBy: "device_risk_proxy",
        rawEvidence: {
          timezone: fingerprint.timezone,
          geoCountry: fingerprint.ipGeolocation.country,
        },
      });
    }
 
    // Check for location mismatch with stated address
    if (borrowerProfile.state && fingerprint.ipGeolocation.region) {
      const geoState = fingerprint.ipGeolocation.region;
      if (
        geoState !== borrowerProfile.state &&
        fingerprint.ipGeolocation.country === "US"
      ) {
        signals.push({
          signalType: "third_party_identity_theft",
          confidence: 0.4,
          description: "Device location does not match applicant's stated address",
          detectedBy: "device_risk_geomismatch",
          rawEvidence: {
            deviceState: geoState,
            statedState: borrowerProfile.state,
          },
        });
      }
    }
 
    // Check for device reuse across multiple identities
    const uniqueIdentities = new Set(
      historicalFingerprints.map((f) => f.deviceId)
    ).size;
    if (historicalFingerprints.length > 3 && uniqueIdentities <= 1) {
      signals.push({
        signalType: "collusion",
        confidence: 0.75,
        description: "Same device used for multiple applications under different identities",
        detectedBy: "device_risk_multiidentity",
        rawEvidence: {
          applicationCount: historicalFingerprints.length,
        },
      });
    }
 
    return signals;
  }
 
  private isLikelyProxy(fingerprint: DeviceFingerprint): boolean {
    // Timezone offset doesn't match geo location
    const expectedTimezone = this.getExpectedTimezone(
      fingerprint.ipGeolocation
    );
    return (
      expectedTimezone !== null &&
      fingerprint.timezone !== expectedTimezone
    );
  }
 
  private getExpectedTimezone(geo: GeoLocation): string | null {
    // Simplified lookup; production would use a timezone database
    return null;
  }
}

Fraud Scoring Aggregation

The final step aggregates signals from rules, velocity checks, device analysis, and any machine learning models into a single fraud risk score.

class FraudScoringService {
  private readonly signalWeights: Record<FraudType, number> = {
    synthetic_identity: 200,
    third_party_identity_theft: 180,
    first_party_income: 100,
    first_party_employment: 90,
    first_party_asset: 80,
    application_manipulation: 120,
    collusion: 160,
    bust_out: 150,
  };
 
  assess(
    applicationId: string,
    signals: FraudSignal[],
    velocityResults: VelocityCheckResult[]
  ): FraudAssessment {
    let riskScore = 0;
 
    // Weighted signal contribution
    for (const signal of signals) {
      const weight = this.signalWeights[signal.signalType] ?? 50;
      riskScore += weight * signal.confidence;
    }
 
    // Velocity contribution
    for (const velocity of velocityResults) {
      if (velocity.triggered) {
        const velocityWeight =
          velocity.action === "block" ? 300 : velocity.action === "challenge" ? 150 : 100;
        riskScore += velocityWeight;
      }
    }
 
    // Cap at 1000
    riskScore = Math.min(Math.round(riskScore), 1000);
 
    return {
      applicationId,
      overallRiskScore: riskScore,
      riskLevel: this.mapRiskLevel(riskScore),
      signals,
      recommendation: this.mapRecommendation(riskScore, velocityResults),
      assessedAt: new Date(),
    };
  }
 
  private mapRiskLevel(
    score: number
  ): FraudAssessment["riskLevel"] {
    if (score >= 700) return "critical";
    if (score >= 400) return "high";
    if (score >= 200) return "medium";
    return "low";
  }
 
  private mapRecommendation(
    score: number,
    velocityResults: VelocityCheckResult[]
  ): FraudAssessment["recommendation"] {
    const hasBlockingVelocity = velocityResults.some(
      (v) => v.triggered && v.action === "block"
    );
 
    if (hasBlockingVelocity || score >= 700) return "block";
    if (score >= 400) return "manual_review";
    if (score >= 200) return "enhanced_review";
    return "proceed";
  }
}

A practical tip: resist the temptation to set the blocking threshold too low. Every blocked legitimate applicant is a lost customer and a negative experience. Start with conservative thresholds that only block the most egregious patterns, and route medium-risk applications to human review where analysts can refine the signals over time.

Continuous Improvement Through Feedback Loops

Fraud detection is not a set-and-forget capability. As fraudsters adapt their techniques, the detection system must evolve in response. Implement a feedback loop where confirmed fraud cases are labeled and fed back into the system to calibrate signal weights and refine rules.

Track key metrics: the false positive rate (legitimate applications flagged as fraud), the false negative rate (fraudulent applications that slipped through), the fraud loss rate, and the detection rate by fraud type. Review these metrics monthly and adjust rules, thresholds, and weights accordingly. Collaborate with the underwriting team and collections team, as they often identify fraud patterns that the automated system missed.

Conclusion

Effective fraud detection in lending requires a multi-layered approach that combines deterministic rules for known patterns, velocity monitoring for volume-based attacks, device analysis for environmental anomalies, and aggregated scoring for holistic risk assessment. TypeScript's type system helps model the complex relationships between fraud signals, check contexts, and assessment outcomes, while its async capabilities support the real-time processing that fraud detection demands.

The key principles are: score rather than classify at the signal level, aggregate multiple weak signals into strong indicators, route medium-risk cases to human review rather than blocking outright, and continuously refine the system through feedback from confirmed outcomes. A fraud detection system that balances precision with recall---catching fraud without alienating legitimate borrowers---is a critical competitive advantage for any lending platform.

Related Articles

technical

Designing APIs for Lending Platforms

A comprehensive guide to designing robust, secure, and developer-friendly APIs for lending platforms, covering RESTful resource modeling, webhook architectures, idempotency, versioning, and partner integration patterns in TypeScript.

12 min read
business

Risk Management in Lending: Architecture and Strategy

A strategic guide to building a comprehensive risk management framework for lending platforms, covering credit risk, portfolio management, stress testing, concentration limits, and loss forecasting.

11 min read
business

Digital Lending Trends: What's Next for Fintech

A business-focused analysis of the trends shaping digital lending, including embedded finance, alternative data, real-time decisioning, open banking, and the evolution of lending-as-a-service platforms.

9 min read