LLM-Powered Credential Stuffing and Synthetic Identity Bots: Defence Beyond Rate Limiting

LLM-Powered Credential Stuffing and Synthetic Identity Bots: Defence Beyond Rate Limiting

The Problem

Traditional credential stuffing was an arithmetic problem. A 2-billion-record combo list, a hit rate of 0.1–0.5%, residential proxies to spread the load, and a CAPTCHA-solver farm. The economics were well-understood. Defenders raised the cost per attempt — rate limits, IP reputation, CAPTCHAs — until the attacker’s expected return turned negative.

That equilibrium broke in 2024–2025. Two converging developments made the arithmetic irrelevant.

LLM-enhanced credential generation. Threat actors began using LLMs as password-mutation engines, applying them against breach databases to produce vastly higher-quality credential candidates. SpyCloud’s 2025 threat report documented a 3–5× increase in hit rates against tested account populations when LLM-mutated lists replaced raw combo list files. The mechanism has three distinct components.

OSINT-augmented password prediction. Given a breach record — email address, old password, username, maybe a hashed or plaintext password from a different site — a model trained on password-pattern corpora can enumerate likely variants: password123Password123, password123!, p4ssword123, password2024, Password123!1. The model also knows the target site’s password policy requirements from public documentation, so it filters the candidate list to only those meeting minimum length, complexity, and special-character rules. A 10,000-entry combo list becomes a 60,000-entry high-confidence candidate list.

Cross-site credential correlation. The LLM correlates breach records across multiple dumps to find users with predictable modification patterns. If alice@example.com used fluffy2019! on one breached site and fluffy2020! on another, the model predicts fluffy2025! as the current-year variant. This temporal extrapolation is trivially generalisable: season–year patterns (Spring2024!Fall2024!Spring2025!), base-word incrementing (letmein1letmein2), and employer-or-city appending (password@Googlepassword@NewCo) are all in-context reasoning the model performs without fine-tuning.

Social media OSINT integration. LLMs process scraped social media profiles — pets’ names, sports teams, birth years, locations visible on LinkedIn, Instagram, and Facebook — to generate personalised password candidates that no generic combo list would contain. A user whose public posts mention a dog named “Biscuit” and a year spent in Austin might use BiscuitAustin2022! — a password a random list would never include, but an LLM with profile access would rank in its top 20 candidates. The 2024 Dark Reading coverage of “targeted stuffing” attacks documented cases where attackers profiled specific high-value accounts before generating the credential list.

Synthetic identity fraud via KYC bypass. The second development is orthogonal to credential stuffing but similarly breaks the rate-limiting model. LLMs combined with diffusion-model image generation produce synthetic identities that pass automated KYC checks at scale:

  • AI-generated face photos that defeat passive liveness detection by using 3D-modelled face videos generated from a single synthetic reference image.
  • LLM-generated consistent personal histories: name, date of birth, SSN issuance region, address history, employment timeline, phone number — each field plausible in isolation and consistent with the others.
  • AI-generated utility bills, driver’s licence images, and passport scans that pass OCR-based document validation. The generator knows what a real Colorado driver’s licence looks like from training data; it produces one with consistent fonts, field placements, and barcode-region textures.

The 2025 Sumsub Identity Fraud Report documented a 340% year-over-year increase in AI-generated document submissions detected at KYC checkpoints. Many of the submissions that were not caught are measurably absent from that dataset. Synthetic identities are used for opening fraudulent credit lines, account farming for resale, money-laundering layering, and bypassing age verification.

Both attack classes share a structural property: they defeat defences predicated on volume and rate, because they shift the attack surface from volume to quality per attempt. You cannot rate-limit your way out of an attack where every fifth attempt succeeds.

Threat Model

  • LLM-mutated stuffing against login endpoints: attacker generates 5–20 personalised password candidates per leaked credential using OSINT + cross-breach correlation. 3–5% success rate defeats rate-limiting economics calibrated for 0.2%.
  • Residential proxy fan-out: each request originates from a different IP and ASN. IP reputation lists are ineffective. The meaningful signal is per-username failure velocity, not per-IP velocity.
  • Credential stuffing against password reset and KBA flows: LLM generates targeted answers to knowledge-based authentication questions (“first car”, “mother’s maiden name”, “high school mascot”) from OSINT. KBA is cryptographically broken for anyone with a social media presence.
  • Synthetic identity account creation at registration: AI-generated KYC documents + demographically consistent synthetic personal data passes automated identity verification. Used to farm accounts, open fraudulent financial products, or establish persistent access for later escalation.
  • Account takeover via synthetic identity recovery: a synthetic identity built to match the profile of a real target user is used to social-engineer the support team into executing a manual account recovery, bypassing all technical controls.
  • AI-generated face video bypassing passive liveness detection: a 3D deepfake video passes the “blink and turn your head” check when the liveness model wasn’t trained on current-generation synthetic faces.

Hardening Configuration

1. Breached Credential Checking with k-Anonymity

The first and highest-ROI control is refusing to accept — or to honour at login — passwords that appear in known breach corpora. Have I Been Pwned’s range API implements k-anonymity: you send only the first 5 hex characters of the SHA-1 hash, and the API returns all suffixes in that prefix bucket. The full password hash never leaves your system.

import hashlib
import httpx
from typing import Optional

async def check_breached_password(password: str, min_count: int = 5) -> Optional[int]:
    """
    Check a password against the HaveIBeenPwned corpus using k-anonymity.
    Returns the breach count if found at or above min_count, None otherwise.
    Never sends the full hash — only the first 5 hex chars of SHA-1.
    """
    sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
    prefix, suffix = sha1[:5], sha1[5:]

    async with httpx.AsyncClient(timeout=2.5) as client:
        response = await client.get(
            f"https://api.pwnedpasswords.com/range/{prefix}",
            headers={
                # Padding ensures all responses are the same size,
                # defeating traffic-analysis correlation.
                "Add-Padding": "true"
            }
        )
        response.raise_for_status()

    for line in response.text.splitlines():
        hash_suffix, count_str = line.split(":")
        if hash_suffix == suffix:
            count = int(count_str)
            if count >= min_count:
                return count
    return None


# Integration in the login handler.
# Run this check on every authentication attempt, not just at password-set time.
# A password that was clean at set-time may appear in a new breach dump tomorrow.
async def handle_login(
    username: str,
    password: str,
    session_ctx: dict
) -> dict:
    breach_count = await check_breached_password(password)
    if breach_count is not None:
        # Don't disclose the reason in the error — that leaks confirmation
        # that the password was found. Log internally for detection.
        log_security_event("breached_password_login_attempt", {
            "username": username,
            "breach_count": breach_count,
            "ip": session_ctx.get("ip"),
            "asn": session_ctx.get("asn"),
        })
        # Require a password change; don't simply deny access or
        # attackers learn which passwords are "safe".
        raise RequirePasswordChange(
            "A security update is required before you can continue."
        )

    # ... remainder of authentication flow

The min_count=5 threshold avoids false positives from hash collisions in the padding scheme. At registration and password change, use min_count=1 — any known breach exposure on a new password is unacceptable. At login, min_count=5 reduces noise from padding artifacts.

Cache the prefix lookups locally. The HIBP range endpoint returns ~800 entries per 5-character prefix. A Redis hash of sha1_prefix → {suffix: count} with a 24-hour TTL means your login path avoids a synchronous external call on every authentication while staying current with new breaches.

async def check_breached_password_cached(
    password: str,
    redis_client,
    min_count: int = 5
) -> Optional[int]:
    sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
    prefix, suffix = sha1[:5], sha1[5:]
    cache_key = f"hibp:range:{prefix}"

    cached = await redis_client.hget(cache_key, suffix)
    if cached is not None:
        return int(cached) if int(cached) >= min_count else None

    # Cache miss — fetch from API
    async with httpx.AsyncClient(timeout=2.5) as client:
        response = await client.get(
            f"https://api.pwnedpasswords.com/range/{prefix}",
            headers={"Add-Padding": "true"}
        )

    pipe = redis_client.pipeline()
    for line in response.text.splitlines():
        h, c = line.split(":")
        pipe.hset(cache_key, h, c)
    pipe.expire(cache_key, 86400)  # 24-hour TTL
    await pipe.execute()

    count = await redis_client.hget(cache_key, suffix)
    if count and int(count) >= min_count:
        return int(count)
    return None

2. LLM-Mutation-Aware Rate Limiting: Per-Username, Not Per-IP

IP-based rate limiting is completely defeated by residential proxy rotation. The correct rate-limiting primitive is the username (or email address) being attacked. Per-username failure velocity is the signal that survives proxy fan-out.

from dataclasses import dataclass
import time

@dataclass
class LoginCheckResult:
    allowed: bool
    require_step_up: bool
    lock_reason: Optional[str]


class CredentialStuffingDetector:
    # Thresholds calibrated for LLM-mutated stuffing:
    # 5-20 mutations per leaked credential means 20 attempts is generous.
    FAILURE_LIMIT_PER_USER_1H = 6
    ATTEMPT_LIMIT_PER_USER_1H = 20
    DISTINCT_USER_LIMIT_PER_ASN_10M = 150  # legitimate corporate ASNs may be high
    FAILURE_LIMIT_PER_ASN_1H = 500

    def __init__(self, redis_client):
        self.redis = redis_client

    async def check_login_attempt(
        self,
        username: str,
        ip: str,
        asn: str,
        user_agent: str
    ) -> LoginCheckResult:
        pipe = self.redis.pipeline()

        # Per-username failure count (sliding 1-hour window)
        user_fail_key = f"login:fail:user:{username}"
        pipe.get(user_fail_key)

        # Per-username total attempt count (sliding 1-hour window)
        user_attempt_key = f"login:attempt:user:{username}"
        pipe.get(user_attempt_key)

        # Distinct usernames from this ASN in the last 10 minutes
        # This catches distributed stuffing across many usernames
        asn_users_key = f"login:asn_users:{asn}"
        pipe.scard(asn_users_key)

        # Per-ASN failure count — survives IP rotation within the ASN
        asn_fail_key = f"login:fail:asn:{asn}"
        pipe.get(asn_fail_key)

        results = await pipe.execute()
        user_failures = int(results[0] or 0)
        user_attempts = int(results[1] or 0)
        asn_distinct_users = int(results[2] or 0)
        asn_failures = int(results[3] or 0)

        if user_failures >= self.FAILURE_LIMIT_PER_USER_1H:
            return LoginCheckResult(
                allowed=False,
                require_step_up=False,
                lock_reason="per_user_failure_limit"
            )

        if asn_failures >= self.FAILURE_LIMIT_PER_ASN_1H:
            return LoginCheckResult(
                allowed=False,
                require_step_up=True,
                lock_reason="asn_failure_limit"
            )

        # High attempt volume from this ASN across many users = stuffing run
        if asn_distinct_users >= self.DISTINCT_USER_LIMIT_PER_ASN_10M:
            return LoginCheckResult(
                allowed=True,
                require_step_up=True,  # Mandate MFA, don't block outright
                lock_reason=None
            )

        return LoginCheckResult(allowed=True, require_step_up=False, lock_reason=None)

    async def record_failure(self, username: str, ip: str, asn: str):
        pipe = self.redis.pipeline()
        pipe.incr(f"login:fail:user:{username}")
        pipe.expire(f"login:fail:user:{username}", 3600)
        pipe.incr(f"login:fail:asn:{asn}")
        pipe.expire(f"login:fail:asn:{asn}", 3600)
        await pipe.execute()

    async def record_attempt(self, username: str, asn: str):
        pipe = self.redis.pipeline()
        pipe.incr(f"login:attempt:user:{username}")
        pipe.expire(f"login:attempt:user:{username}", 3600)
        pipe.sadd(f"login:asn_users:{asn}", username)
        pipe.expire(f"login:asn_users:{asn}", 600)
        await pipe.execute()

ASN tracking is the critical addition here. Residential proxy networks rotate through tens of thousands of IP addresses, but the pool of residential ASNs is finite. Tracking per-ASN failure velocity and per-ASN distinct username access detects stuffing campaigns that would otherwise be invisible behind IP rotation.

3. ASN-Level Rate Limiting at the Nginx Edge

Complementing application-layer ASN detection, you can enforce coarse ASN-level throttling in nginx using the MaxMind GeoIP2 ASN database. This provides a rate limit that survives IP rotation within a proxy network:

# /etc/nginx/conf.d/geoip2.conf
# Requires ngx_http_geoip2_module (compiled in or loaded as a dynamic module)
# and the MaxMind GeoLite2-ASN.mmdb database at /var/lib/geoip2/GeoLite2-ASN.mmdb

geoip2 /var/lib/geoip2/GeoLite2-ASN.mmdb {
    $geoip2_asn autonomous_system_number;
    $geoip2_asn_org autonomous_system_organization;
}

# Build a combined key from ASN + a bucketed timestamp (10-minute window)
# to spread the rate limit across windows naturally.
map $geoip2_asn $asn_ratelimit_key {
    default $geoip2_asn;
}

# 10 requests per minute per ASN on the login endpoint.
# Legitimate corporate egress: hundreds of employees may share an ASN —
# bump this to 50r/m for known-good ASNs via a separate map.
limit_req_zone $asn_ratelimit_key zone=login_asn:20m rate=10r/m;

# Per-IP limit retained as a secondary control
limit_req_zone $binary_remote_addr zone=login_ip:10m rate=5r/m;

server {
    listen 443 ssl http2;
    server_name example.com;

    location /api/auth/login {
        # ASN limit: burst 20, meaning up to 20 requests are queued before
        # returning 429. nodelay means queued requests aren't delayed — they
        # either proceed immediately or 429.
        limit_req zone=login_asn burst=20 nodelay;
        limit_req zone=login_ip burst=10 nodelay;
        limit_req_status 429;

        # Expose the ASN to the upstream for application-layer tracking
        proxy_set_header X-Client-ASN $geoip2_asn;
        proxy_set_header X-Client-ASN-Org $geoip2_asn_org;
        proxy_pass http://auth_backend;
    }
}

Install the MaxMind GeoLite2-ASN database with their mmdb-bin tool and automate weekly updates via cron — the ASN assignment database changes frequently as IP blocks are transferred between operators.

4. FIDO2/WebAuthn — Eliminating the Password Attack Surface

The most complete mitigation against credential stuffing is eliminating passwords for accounts that can migrate to passkeys. A FIDO2 credential is bound to the specific origin it was registered against; there is no password to exfiltrate, guess, or stuff. This is not a theoretical hardening — it is a categorical removal of the attack surface.

// Passkey registration — call after collecting the username.
// The server generates the challenge and credential creation options.
async function registerPasskey(username) {
  // Step 1: fetch server-generated options including the challenge
  const optionsResponse = await fetch('/api/auth/passkey/register/options', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({username})
  });
  const options = await optionsResponse.json();

  // Convert base64url-encoded buffers that the server sends as strings
  options.challenge = base64urlToBuffer(options.challenge);
  options.user.id = base64urlToBuffer(options.user.id);
  if (options.excludeCredentials) {
    options.excludeCredentials = options.excludeCredentials.map(c => ({
      ...c,
      id: base64urlToBuffer(c.id)
    }));
  }

  // Step 2: invoke the browser's WebAuthn API
  // residentKey: 'required' — passkey is stored on the authenticator,
  // enabling passwordless authentication without a username entry.
  // userVerification: 'required' — always require biometric or PIN.
  const credential = await navigator.credentials.create({
    publicKey: {
      ...options,
      authenticatorSelection: {
        residentKey: 'required',
        userVerification: 'required',
        // Prefer platform authenticators (Face ID, Windows Hello, Android biometric)
        // as they are the lowest-friction path for most users.
        authenticatorAttachment: 'platform'
      },
      // Algorithms: ES256 preferred (P-256), RS256 as fallback for Windows Hello
      pubKeyCredParams: [
        {type: 'public-key', alg: -7},   // ES256
        {type: 'public-key', alg: -257}  // RS256
      ]
    }
  });

  // Step 3: send the attestation to the server for verification and storage
  const registrationResponse = await fetch('/api/auth/passkey/register/verify', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      id: credential.id,
      rawId: bufferToBase64url(credential.rawId),
      response: {
        clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
        attestationObject: bufferToBase64url(credential.response.attestationObject)
      },
      type: credential.type
    })
  });

  return registrationResponse.json();
}

// Passkey authentication — no username entry required for discoverable credentials
async function authenticateWithPasskey() {
  const optionsResponse = await fetch('/api/auth/passkey/login/options', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({})
  });
  const options = await optionsResponse.json();
  options.challenge = base64urlToBuffer(options.challenge);

  const assertion = await navigator.credentials.get({
    publicKey: {
      ...options,
      userVerification: 'required'
    }
  });

  const loginResponse = await fetch('/api/auth/passkey/login/verify', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      id: assertion.id,
      rawId: bufferToBase64url(assertion.rawId),
      response: {
        clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
        authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
        signature: bufferToBase64url(assertion.response.signature),
        userHandle: assertion.response.userHandle
          ? bufferToBase64url(assertion.response.userHandle) : null
      },
      type: assertion.type
    })
  });

  return loginResponse.json();
}

function base64urlToBuffer(base64url) {
  const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
  const binStr = atob(base64);
  return Uint8Array.from(binStr, c => c.charCodeAt(0)).buffer;
}

function bufferToBase64url(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

Critically: the passkey fallback path must not reintroduce a stuffable password. If your passkey-enrolled users can still log in with their email and password as a fallback, attackers will target the fallback. The correct architecture for a user who loses their authenticator is a quorum-based recovery flow (two trusted contacts confirm the identity), not a password fallback. The operational cost of that recovery is the price of the security property.

5. Synthetic Identity Detection at KYC

Synthetic identities generated by LLMs have characteristic statistical signatures even when individual fields look plausible:

from dataclasses import dataclass
from typing import Callable
import re

@dataclass
class SyntheticIdentityRule:
    name: str
    check: Callable
    weight: float
    description: str


def ssn_issuance_year_from_last4(ssn_last4: str) -> Optional[int]:
    """
    SSN area numbers (first 3 digits) encode the issuing state and, for
    numbers issued before 2011, approximately the decade of issuance.
    Post-2011 SSNs are randomised. Last-4 alone can't reconstruct this,
    but combined with area number from partial SSN it can.
    For a full implementation, use a lookup table of area-number ranges
    mapped to approximate issuance years.
    """
    # Placeholder: in production, use the full SSN (masked/tokenised) with
    # the SSNVS API or a local area-number lookup table.
    return None


SYNTHETIC_IDENTITY_RULES = [
    SyntheticIdentityRule(
        name="dob_suspiciously_round",
        # DOB on January 1st or first of any month is a strong synthetic signal.
        # Real population: ~3.3% born on 1st of month.
        # Synthetic: 8-12% — generators default to clean dates.
        check=lambda i: i["dob"].day == 1 or i["dob"].month == 1,
        weight=0.25,
        description="Birth date is suspiciously round (first of month or January)"
    ),
    SyntheticIdentityRule(
        name="address_is_virtual_office",
        # Match against a curated database of known mail-drop, virtual office,
        # UPS Store, and freight-forwarding addresses.
        # Commercial sources: SmartyStreets, USPS CASS with vacancy flag.
        check=lambda i: i["address_metadata"].get("is_virtual_office", False),
        weight=0.55,
        description="Address matches known mail-drop or virtual office database"
    ),
    SyntheticIdentityRule(
        name="phone_is_voip",
        # Twilio Lookup, NumVerify, or Neustar PhoneID returns carrier type.
        # Real population VoIP rate: ~8%. Synthetic identity VoIP rate: ~60%.
        check=lambda i: i["phone_metadata"].get("line_type") in (
            "voip", "virtual", "prepaid"
        ),
        weight=0.30,
        description="Phone number is VoIP, virtual, or prepaid"
    ),
    SyntheticIdentityRule(
        name="name_dob_no_public_record",
        # Cross-reference against LexisNexis RiskView, Experian Precise ID,
        # or a voter-rolls + public-records API. Real people have a credit
        # file, voter registration, or property record.
        check=lambda i: not i["public_record_found"],
        weight=0.45,
        description="No public record matches name + DOB + state combination"
    ),
    SyntheticIdentityRule(
        name="ssn_area_mismatches_birth_state",
        # An SSN area number typically encodes the state of issuance
        # (for pre-2011 SSNs). A person born in Texas in 1988 with an
        # Oregon-range SSN area number is anomalous.
        check=lambda i: i.get("ssn_state_mismatch", False),
        weight=0.40,
        description="SSN area number inconsistent with claimed birth state or year"
    ),
    SyntheticIdentityRule(
        name="session_typing_is_too_clean",
        # Synthetic identity submissions tend to have no typos, no corrections,
        # and uniform inter-keystroke timing. Real users make mistakes.
        # This requires keystroke dynamics collection in the frontend form.
        check=lambda i: i["session_metadata"].get("correction_count", 1) == 0
            and i["session_metadata"].get("form_completion_seconds", 60) < 15,
        weight=0.20,
        description="Form filled with no corrections and unnaturally fast completion"
    ),
]


def score_synthetic_identity(
    identity: dict,
    session_metadata: dict
) -> tuple[float, list[str]]:
    """
    Returns (risk_score, list_of_triggered_rules).
    Score >= 0.7: route to manual review queue.
    Score >= 0.9: auto-reject with 'identity verification failed' messaging.
    """
    identity["session_metadata"] = session_metadata
    triggered = []
    total_weight = 0.0

    for rule in SYNTHETIC_IDENTITY_RULES:
        try:
            if rule.check(identity):
                total_weight += rule.weight
                triggered.append(rule.name)
        except (KeyError, TypeError):
            # Missing field is itself a weak signal but don't score it
            pass

    return min(total_weight, 1.0), triggered

6. Liveness and Document Integrity Checks Against AI-Generated Media

Passive liveness detection (the system watches the user’s face without prompting) is broken for 2025-generation deepfake video tools. Active liveness (the user follows randomised on-screen prompts) is substantially harder to defeat in real time, though pre-rendered 3D face models can be controlled to match prompts.

Configure your KYC provider (Persona, Onfido, or Jumio) with the following settings — using Persona’s workflow YAML schema as an example, but the concepts map directly to the other vendors’ APIs:

# Persona inquiry template configuration
# Requires Persona API v2 with the Advanced IDV add-on for deepfake detection.
inquiry_template:
  name: "Standard KYC with AI-Fraud Controls"
  verification_type: government_id_and_selfie
  options:
    # Active liveness: user must follow randomised head-movement prompts.
    # Passive-only liveness has been defeated by 2025-era deepfake video generation.
    liveness_type: active
    liveness_challenge_count: 3
    liveness_challenge_randomise: true

    # Selfie-to-ID biometric match threshold.
    # 0.94 is high; drop to 0.90 for broader population coverage at the cost
    # of higher deepfake pass-through risk.
    selfie_match_threshold: 0.94

    # Deepfake score: reject submissions where the AI-generation confidence
    # exceeds 0.05. Tune this against your false-positive rate on known-good users.
    deepfake_score_threshold: 0.05

    # PRNU (Photo Response Non-Uniformity) check: compares the sensor noise
    # fingerprint of the document image against previously-seen submissions.
    # Same sensor noise pattern = same camera source, which flags reuse of a
    # single AI-generated base document across multiple synthetic identities.
    prnu_uniqueness_check: true

    # Device attestation: requires iOS or Android OS-level attestation that
    # the camera output originated from the physical sensor, not a screen
    # capture or virtual camera input.
    require_device_attested_capture: true

    # Document: require that the ID contains the expected MRZ, barcode,
    # and NFC chip (where applicable). AI-generated documents often produce
    # plausible-looking but cryptographically invalid MRZ check digits or
    # missing RFID chip data on modern passports.
    document_options:
      require_mrz_checksum_valid: true
      require_barcode_parseable: true
      detect_screen_of_screen_capture: true  # Moiré pattern detection
      detect_digital_tampering: true

For PRNU-based reuse detection: the Sumsub and Sardine platforms implement cross-submission sensor fingerprinting natively. If building in-house, the academic library prnu (available via PyPI) extracts the sensor noise pattern from JPEG images and can be used to cluster submissions that originated from the same generation source.

7. Compromised Credential Monitoring and Proactive Reset

Reactive breach checking at login catches users whose credentials are already in the wild. Proactive monitoring — subscribing to breach notification feeds for your organisation’s email domains — lets you force password rotations before attackers exploit the exposure.

# Register your domain with the HIBP Enterprise Notification API.
# This sends a webhook when a new breach includes addresses at your domain.
curl -s -X POST \
  "https://haveibeenpwned.com/api/v3/subscriptions" \
  -H "hibp-api-key: ${HIBP_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "company.com",
    "webhookUrl": "https://security.company.com/webhooks/hibp-breach"
  }'

Implement the webhook handler to trigger proactive resets:

from fastapi import FastAPI, Request, HTTPException
import hmac, hashlib

app = FastAPI()

HIBP_WEBHOOK_SECRET = os.environ["HIBP_WEBHOOK_SECRET"]


@app.post("/webhooks/hibp-breach")
async def hibp_breach_notification(request: Request):
    # Verify the HMAC signature on the webhook payload
    sig_header = request.headers.get("X-HIBP-Webhook-Signature", "")
    body = await request.body()
    expected_sig = hmac.new(
        HIBP_WEBHOOK_SECRET.encode(),
        body,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(f"sha256={expected_sig}", sig_header):
        raise HTTPException(status_code=401, detail="Invalid signature")

    payload = await request.json()
    breach_name = payload["Name"]
    breach_date = payload["BreachDate"]
    pwned_accounts = payload.get("PwnedAccounts", [])

    for email in pwned_accounts:
        # Flag the account: on next login, force a password change.
        # Do NOT silently reset the password — that locks users out
        # and creates a support storm.
        await flag_account_for_rotation(email, reason=f"breach:{breach_name}")

        # Optionally send a heads-up email:
        await send_security_notification(email, breach_name=breach_name)

    # Log for SIEM ingestion
    log_security_event("breach_notification_processed", {
        "breach": breach_name,
        "breach_date": breach_date,
        "affected_accounts": len(pwned_accounts),
    })

    return {"status": "processed"}

Commercial alternatives to HIBP Enterprise — SpyCloud, Recorded Future Identity, and Flare — provide broader coverage including Telegram channel data and darknet markets that HIBP does not index. At enterprise scale, running two independent feeds and taking the union of flagged accounts is worth the cost.

Expected Behaviour

After deploying these controls:

  • LLM-mutated stuffing campaign: the first 6 failed attempts against any given username trigger the per-username lock and route subsequent attempts to a CAPTCHA/MFA step-up. The ASN-level counter catches distributed campaigns that spread attempts across thousands of usernames — after 500 failures from a single ASN in an hour, all subsequent login attempts from that ASN require step-up verification.
  • Breached password in use: a user attempting to log in with a password that appears in any breach corpus is redirected to a forced password change flow. The error message does not indicate why — it says “a security update is required” — to avoid confirming to an attacker which passwords are clean versus flagged.
  • Synthetic identity at registration: a submission scoring above 0.7 (VoIP phone + virtual office address + no public record) enters the manual review queue with a review SLA. Above 0.9, it auto-rejects with a generic “identity verification unsuccessful” message. A legitimate user from an underbanked or off-grid demographic who triggers the rules manually reviews and approves.
  • Deepfake liveness submission: the KYC provider rejects submissions where the deepfake score exceeds the configured threshold or where PRNU fingerprinting matches a previously-seen submission base. The applicant is told the verification failed and to retry in a well-lit environment — a deliberately ambiguous message.
  • Passkey-enrolled user: credential stuffing is categorically impossible. The attacker has no password to guess. The only viable path is device theft (physical access) or recovery-flow social engineering, which is closed by the quorum recovery requirement.

Smoke-test the configuration:

# Verify the per-username failure limit is enforced.
# Run 7 failed login attempts for the same email and confirm the 7th returns 429.
for i in $(seq 1 7); do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -X POST https://idp.example.com/api/auth/login \
    -H "Content-Type: application/json" \
    -d "{\"email\":\"testuser@example.com\",\"password\":\"wrongpassword${i}\"}")
  echo "Attempt ${i}: HTTP ${STATUS}"
done
# Expected output:
# Attempt 1..6: HTTP 401
# Attempt 7: HTTP 429

# Verify the breached password rejection on new account registration.
curl -s -X POST https://idp.example.com/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"newuser@example.com","password":"Password123!"}' \
  | jq .
# Expected: {"error": "password_policy_violation", "message": "...security update required..."}
# "Password123!" appears 247,011 times in the HIBP corpus.

Trade-offs and Operational Considerations

WebAuthn/passkeys require browser support and user education. All major browsers have supported the WebAuthn API since 2019, and passkey syncing (via iCloud Keychain, Google Password Manager, and 1Password) makes cross-device passkey management viable for most users. The genuine friction point is users who share accounts — a practice that is incompatible with passkeys by design. Treat shared-account users as a policy problem, not a technical one; the solution is service accounts with scoped API keys, not shared passwords.

The password fallback reintroduces the attack surface. Every site that launches passkeys while maintaining a password fallback has created a two-tier attack surface: the passkeys are secure, and attackers simply target the password path. The phased migration path is: (1) launch passkeys, (2) prompt password-using accounts to register a passkey, (3) after 90 days, disable password-only login for accounts that have a passkey registered, (4) after 180 days, disable password login entirely for all accounts. Step 3 is where most organisations stall — resist the stall.

Breached credential checking adds latency to every login. The Redis-cached implementation described above adds approximately 1–5ms for cache hits and 50–150ms for cache misses (synchronous HIBP API call on a warm network). Cache hit rate after the first few days exceeds 95% for active password corpora. For applications where login latency is critical, run the check asynchronously and defer the block to the next request if the current one completes before the check resolves — flagging the session for step-up rather than blocking.

ASN-level rate limiting may affect legitimate users. A corporate network where all traffic egresses through a single carrier ASN will have its legitimate users affected if the ASN trips the failure threshold from a separate stuffing campaign that happens to route through the same ASN. Mitigation: maintain an allowlist of known-high-volume enterprise ASNs where the failure rate is manually reviewed before the ASN-level limit triggers automatic blocking. The per-username limit remains in effect regardless.

The synthetic identity scorer has false positives at the margins. Legitimate users who recently moved to a virtual office address, use a VoIP number as their primary, and don’t have a credit history (new graduates, recent immigrants) can score in the medium-risk band. The correct response is a manual review queue with a human reviewing the decision, not auto-rejection. Tune the auto-reject threshold conservatively (0.9+) and use the manual review queue generously.

Failure Modes

LLM-generated credential variations not in HIBP. The HIBP corpus indexes passwords that appeared in breach dumps. A password generated by an LLM as a prediction of what a user’s current password might be — Fluffy2025! — is not in any breach dump if the user has not been breached yet. HIBP cannot detect it. The mitigations here are the per-username failure velocity limit (which catches repeated guessing regardless of whether the guesses are novel) and the edit-distance check against previously-leaked passwords for the same user (which blocks the obvious mutation paths even when the specific mutation isn’t in a breach corpus). These are probabilistic defences, not categorical ones; a lucky first-attempt mutation won’t be caught.

Synthetic identity detection misses high-quality AI-generated documents. As LLM and diffusion model capabilities improve, the tells that current detection systems rely on (MRZ check-digit errors, incorrect document field geometry, PRNU reuse) will be fixed in next-generation tooling. PRNU-based detection in particular depends on synthetic documents reusing a small pool of base images — a well-resourced attacker generating a unique base image per submission removes this signal. Detection will remain a probabilistic measure, not a categorical one. The practical mitigation is layering: document analysis, behavioral signals (form-fill patterns, device fingerprint), liveness, and public-record cross-reference are independent signals that must all be defeated simultaneously.

Passkey fallback to password reintroduces stuffing risk. As described in the trade-offs section, any passkey deployment that maintains a live password fallback is only partially hardened. Attackers specifically probe for fallback paths. Monitor the split between passkey and password authentication attempts; a stuffing campaign in progress will show up as an anomalous increase in password-path attempts even as passkey-path attempts remain normal.

KYC vendor liveness detection outpaced by deepfake generation. KYC vendors update their anti-deepfake models on a lag relative to public deepfake tool capabilities. The PRNU and active-liveness controls degrade as each new generation of deepfake tooling is released. Stay subscribed to vendor security advisories; if a major deepfake capability leap is public, treat your current liveness controls as temporarily degraded and increase manual review rates until the vendor ships updated models.

HIBP webhook delivery failure causes silent gap. If the webhook endpoint is down when a new breach notification is delivered, affected accounts are not flagged for rotation. Implement a webhook delivery retry check: HIBP retries failed webhooks, but also poll the HIBP enterprise API daily for new breaches affecting your domain as a belt-and-suspenders backup to the push webhook.