Skip to content

We have fraud detection at home

How forminator and markov-mail implement multi-layer fraud detection for form submissions on Cloudflare Workers. Two Workers — one handling form submissions with 6 detection layers and 10-component risk scoring, the other running Random Forest ML inference on email addresses — connected via RPC service bindings.

This serves as a reference template for building similar systems. All patterns are derived from production code.


d2 diagram
ComponentRoleTech
Form WorkerSubmission intake, multi-layer fraud detection, risk scoringHono on Workers, D1, KV
ML WorkerEmail fraud classification, feature extraction, model inferenceHono on Workers, D1, 3x KV
Service BindingWorker-to-Worker RPC (zero overhead, same-thread execution)WorkerEntrypoint from cloudflare:workers
TurnstileCAPTCHA validation, ephemeral device IDs (Enterprise)Cloudflare Turnstile
Bot ManagementJA4 fingerprints, bot scores, JS detection (Enterprise)Cloudflare Bot Management

The fraud pipeline runs in four phases. Each layer is independent — failure in one doesn’t block the others.

d2 diagram

Pre-Turnstile check against a TTL-based blacklist. Matches on email, ephemeral ID, JA4 fingerprint, or IP address (checked in that priority order, most specific first). Returns in single-digit milliseconds and short-circuits the entire pipeline.

// Check order: most specific identifier first
async function checkPreValidationBlock(
ephemeralId: string | null,
remoteIp: string,
ja4: string | null,
email: string | null,
db: D1Database,
): Promise<PreValidationResult> {
const identifiers = [
{ type: "email", value: email },
{ type: "ephemeral_id", value: ephemeralId },
{ type: "ja4", value: ja4 },
{ type: "ip_address", value: remoteIp },
].filter((id) => id.value != null);
for (const { type, value } of identifiers) {
const entry = await db
.prepare(
`SELECT * FROM fraud_blacklist
WHERE identifier_type = ? AND identifier_value = ?
AND expires_at > ?`,
)
.bind(type, value, new Date().toISOString())
.first();
if (entry && ["high", "medium"].includes(entry.confidence)) {
return {
blocked: true,
reason: entry.detection_type,
confidence: entry.confidence,
};
}
}
return { blocked: false };
}

The form Worker calls the ML Worker via a service binding. The RPC call has zero overhead per Cloudflare’s documentation — both Workers execute on the same thread of the same server.

// Form Worker: call ML Worker via service binding
async function checkEmailFraud(
email: string,
env: Env,
request?: Request,
): Promise<EmailFraudResult | null> {
try {
// Pass request headers so ML Worker has access to
// geo, network, and bot management signals
const headers: Record<string, string | null> = {};
if (request?.cf) {
headers["cf-ipcountry"] = request.headers.get("cf-ipcountry");
headers["cf-connecting-ip"] = request.headers.get("cf-connecting-ip");
// ... additional cf headers for fingerprinting
}
const result = await env.FRAUD_DETECTOR.validate({
email,
consumer: "form-worker",
flow: "submission",
headers,
});
return {
riskScore: result.riskScore * 100, // ML returns 0-1, scoring expects 0-100
decision: result.decision,
signals: result.signals,
};
} catch {
return null; // Fail-open: ML unavailable = 0 risk
}
}

Collects three signals from D1 using Turnstile’s ephemeral device ID (Enterprise) to detect volume abuse:

SignalQueryDetection
Submission countCount submissions by ephemeral ID in 24hForm stuffing from same device
Validation frequencyCount Turnstile validations by ephemeral ID in 1hRapid-fire CAPTCHA solving
IP diversityCount distinct IPs per ephemeral ID in 24hVPN/proxy rotation from same device
async function collectEphemeralIdSignals(
ephemeralId: string,
db: D1Database,
config: FraudDetectionConfig,
): Promise<EphemeralIdSignals> {
try {
const [submissions, validations, ips] = await Promise.all([
db
.prepare(
`SELECT COUNT(*) as count FROM submissions
WHERE ephemeral_id = ? AND created_at > datetime('now', '-24 hours')`,
)
.bind(ephemeralId)
.first<{ count: number }>(),
db
.prepare(
`SELECT COUNT(*) as count FROM turnstile_validations
WHERE ephemeral_id = ? AND validated_at > datetime('now', '-1 hour')`,
)
.bind(ephemeralId)
.first<{ count: number }>(),
db
.prepare(
`SELECT COUNT(DISTINCT ip) as count FROM (
SELECT ip_address as ip FROM submissions
WHERE ephemeral_id = ? AND created_at > datetime('now', '-24 hours')
UNION
SELECT ip_address as ip FROM turnstile_validations
WHERE ephemeral_id = ? AND validated_at > datetime('now', '-24 hours')
)`,
)
.bind(ephemeralId, ephemeralId)
.first<{ count: number }>(),
]);
return {
submissionCount: submissions?.count ?? 1,
validationCount: validations?.count ?? 1,
uniqueIPCount: ips?.count ?? 1,
};
} catch {
return { submissionCount: 1, validationCount: 1, uniqueIPCount: 1 }; // Fail-open baseline
}
}

Uses Bot Management’s JA4 fingerprint (Enterprise) to detect session hopping — multiple distinct devices sharing the same TLS fingerprint, which indicates automated tooling.

Three sub-layers with increasing scope:

Sub-layerScopeWindowThresholdCatches
4a: IP ClusteringSame JA4 + same IP/subnet1 hour2+ ephemeral IDsBot farm on single network
4b: Rapid GlobalSame JA4, any IP5 min3+ ephemeral IDsVPN-hopping automation
4c: Extended GlobalSame JA4, any IP1 hour5+ ephemeral IDsSlow distributed attacks
interface ClusteringAnalysis {
ja4: string;
ephemeralCount: number;
submissionCount: number;
timeSpanMinutes: number;
avgBotScore: number | null;
}
function calculateCompositeRiskScore(
analysis: ClusteringAnalysis,
config: FraudDetectionConfig,
): number {
let rawScore = 0;
// Signal 1: Clustering (multiple devices sharing JA4)
if (analysis.ephemeralCount >= 2) {
let clusterScore = 80;
// Household mitigation: halve score if bot score indicates human
if (analysis.avgBotScore && analysis.avgBotScore >= 50 && !isRapid) {
clusterScore = Math.round(clusterScore / 2);
}
rawScore += clusterScore;
}
// Signal 2: Velocity (submissions too close together)
if (analysis.timeSpanMinutes < config.ja4.velocityThreshold) {
rawScore += 60;
}
// Signal 3a: Global anomaly (high IP distribution + local clustering)
if (ja4Signals?.ips_quantile_1h > config.ja4.ipsQuantileThreshold) {
rawScore += 50;
}
// Signal 3b: Bot pattern (high request volume + local clustering)
if (ja4Signals?.reqs_quantile_1h > config.ja4.reqsQuantileThreshold) {
rawScore += 40;
}
// Max raw = 230, normalized to 0-100 by scoring module
return rawScore;
}

All signals feed into a 10-component weighted score. The architecture ensures every decision is auditable — the full breakdown is stored alongside each submission.

ComponentWeightSourceWhat it measures
Token Replay0.28TurnstileReused CAPTCHA token (binary: 0 or 100)
Email Fraud0.14ML Worker RPCML-classified email fraud probability
Ephemeral ID0.15D1 querySubmission volume from same device
Validation Frequency0.10D1 queryCAPTCHA solve rate from same device
IP Diversity0.07D1 queryIP rotation from same device
JA4 Session Hopping0.06D1 query + Bot MgmtTLS fingerprint clustering
IP Rate Limit0.07D1 queryBrowser-switching OR email diversity from same IP
Header Fingerprint0.07Request headersHeader pattern reuse across ephemeral IDs
TLS Anomaly0.04D1 + Bot MgmtFingerprint baseline deviation
Latency Mismatch0.02Request timingGeo vs network latency inconsistency

Weights must sum to 1.0. The system auto-normalizes after config overrides.

The IP Rate Limit component is a composite of two sub-signals — submission frequency per IP and email diversity per IP — combined via Math.max(). The email diversity signal detects form spam with unique emails from the same address:

async function collectEmailDiversitySignal(
remoteIp: string,
db: D1Database,
config: FraudDetectionConfig,
): Promise<{ distinctEmails: number; riskScore: number }> {
const result = await db
.prepare(
`SELECT COUNT(DISTINCT email) as count FROM submissions
WHERE remote_ip = ? AND created_at > datetime('now', '-1 hour')`,
)
.bind(remoteIp)
.first<{ count: number }>();
const distinctEmails = (result?.count ?? 0) + 1; // +1 for current submission
let riskScore: number;
if (distinctEmails <= 1) riskScore = 0;
else if (distinctEmails === 2)
riskScore = 20; // Could be household
else if (distinctEmails === 3)
riskScore = 60; // Suspicious
else riskScore = 100; // Definite form spam
return { distinctEmails, riskScore };
}
d2 diagram

When signals are inactive (at baseline), their weight is redistributed proportionally to active signals. This prevents the score from being suppressed when some detection layers aren’t available (e.g., no Enterprise features, ML Worker down).

// Identify inactive signals (at baseline values)
const inactiveWeight = components
.filter((c) => c.score === 0 || c.score === baselineForComponent(c))
.reduce((sum, c) => sum + c.weight, 0);
// Redistribute to active signals
const normalizationFactor = 1.0 / (1.0 - inactiveWeight);
for (const component of activeComponents) {
component.contribution =
component.score * component.weight * normalizationFactor;
}

When 3+ independent signals score above a threshold (default: 30), a bonus (default: +15 points) is added. This rewards convergent evidence from different detection methods. All three parameters are configurable via risk.corroboration in the config:

corroboration: {
threshold: 30, // Minimum component score to count as "corroborating"
minSignals: 3, // Number of corroborating signals required
bonus: 15, // Flat bonus added to the score when triggered
},

Certain triggers immediately floor the score at the block threshold, regardless of the weighted calculation. Each has qualification requirements to prevent false positives:

TriggerQualification
token_replayAlways qualifies (fail-secure)
turnstile_failedAlways qualifies (fail-secure)
email_fraudSelf-sufficient (ML confidence)
ephemeral_id_fraudRequires elevated validation frequency OR IP diversity
ja4_session_hoppingRequires raw score at least 140 AND IP rate limit score at least 25
validation_frequencyRequires ephemeral ID score above threshold
ModeBehaviorUse case
defensiveDeterministic triggers can override weighted scoreProduction default
additivePurely weighted, no overridesA/B testing, tuning

Every score adjustment is recorded:

interface ScoringDecision {
baseScore: number; // Raw weighted sum
normalizedScore: number; // After weight redistribution
adjustedScore: number; // After corroboration bonus
finalScore: number; // After deterministic floors
weightRedistribution?: {
inactiveWeight: number;
normalizationFactor: number;
};
corroborationBonus?: {
applied: boolean;
bonus: number;
corroboratingSignals: string[];
};
deterministicBlock?: {
trigger: string;
qualified: boolean;
};
}

When the final score exceeds the block threshold, a blockTrigger is assigned based on which signal was the primary cause. The evaluation order matters — it determines the detection type recorded for forensic analysis and the user-facing error message.

PriorityConditionblockTriggerdetectionType
1ML email decision = blockemail_fraudemail_fraud_detection
2Ephemeral submission count exceeds thresholdephemeral_id_fraudephemeral_id_tracking
3Validation count exceeds frequency thresholdvalidation_frequencyephemeral_id_tracking
4Validation burst (5+ validations, fewer than 2 submissions)validation_frequencyephemeral_id_tracking
5Unique IP count exceeds IP diversity thresholdip_diversityephemeral_id_tracking
6JA4 clustering detectedja4_session_hoppingja4_fingerprinting
7Fingerprint anomaly triggeredper-signalfingerprint_anomaly

IP rate limit is intentionally excluded as a block trigger (see note above).

Duplicate email submissions use a tiered approach that distinguishes user error from automated probing:

AttemptResponseBlacklistRationale
1st-2nd409 Conflict with friendly messageLow-confidence tracking entry (24h)Likely user re-submission
3rd+429 Too Many Requests with wait timeHigh-confidence entry with progressive timeoutAutomated probing pattern

The escalation happens because the blacklist tracks duplicate attempts per email+IP combination. On the 3rd attempt, the system calculates a progressive timeout and adds a high-confidence blacklist entry that triggers the Layer 0 fast path for subsequent requests.


All form inputs pass through Zod schema validation and HTML sanitization before entering the fraud detection pipeline.

Schema validation (Zod):

  • Names: 1-50 chars, Unicode-aware pattern (\p{L}\s'-)
  • Email: max 100 chars, standard format
  • Phone: optional, normalized to E.164 format (strip non-digits, add +1 prefix), validated against ^\+[1-9]\d{1,14}$
  • Address: optional, country required if any address field present
  • Date of birth: optional, YYYY-MM-DD, must be 18-120 years old

HTML sanitization applied to all text inputs:

function sanitizeString(input: string): string {
return input
.replace(/<[^>]*>?/g, "") // Strip HTML tags
.replace(/&#?\w+;/g, "") // Strip HTML entities
.replace(/javascript\s*:/gi, "") // Strip javascript: URIs
.replace(/data\s*:/gi, "") // Strip data: URIs
.replace(/on\w+\s*=/gi, "") // Strip inline event handlers
.trim();
}

The ML Worker extracts a 45-dimension feature vector from an email address and scores it with a Random Forest classifier. It serves as both a standalone HTTP API and an RPC service.

Features span seven categories:

CategoryExamplesCount
IdentityLocal part length, digit ratio, word boundaries, segment count~8
LinguisticPronounceability, vowel ratio, consonant clusters, bigram entropy~7
StatisticalShannon entropy, character distribution, Benford’s Law conformance~6
N-gramCharacter bigram/trigram naturalness across language models~8
StructuralPlus-addressing, sequential patterns, date patterns, pattern family~6
Domain/MXDisposable domain flag, TLD risk, MX provider category~5
Geo/NetworkLanguage mismatch, timezone mismatch, name-email similarity~5
d2 diagram

The 45 features are produced by specialized detector modules. Each operates independently and returns structured results.

Identifies user123, test001, account42 style emails. Two patterns: trailing numbers (/^(.+?)(\d+)$/) and middle numbers with separators (/^(.+?)[._-](\d+)[._-](.+)$/).

Confidence factors: sequence length (+0.25 to +0.7), leading zeros (+0.3), digit ratio (+0.1 to +0.2), common bot bases (+0.25). Threshold: confidence >= 0.4 marks as sequential.

Exemptions (reduce false positives):

  • Birth years (1940-present, ages 13-100): john1990@ is not sequential
  • Small memorable numbers (3 or fewer digits, base 4+ chars, no leading zeros, no common base): mike42@ is not sequential

Common bot bases: test, user, account, email, temp, demo, admin, guest, trial, sample, hello, service, team, info, support, member.

Identifies date components in email local parts. Five format patterns checked in priority order (most specific first): full date (20241031), month+year (oct2024), four-digit year (2024), leading year (2024.username), two-digit year (24).

Age-aware classification is the key insight — birth years get low risk, recent years get high risk:

CategoryYear AgeRiskExample
futurenegative (year > current)0.95user2027@
recent_timestamp0-20.90user2025@
underage3-120.70user2015@
plausible_birth_year13-650.20john1990@
elderly_birth_year66-1000.40user1940@
ancient>1000.80user1900@

Three feature groups measuring how “natural” an email local part looks:

  • Linguistic: Pronounceability (composite: vowel ratio, consonant cluster penalties, impossible cluster detection), vowel/consonant ratios, max cluster lengths, syllable estimate. Context-dependent y/w classification (e.g., y is a vowel when not adjacent to vowels).
  • Structure: Word boundary detection (., _, -), segment count and length statistics, segments-without-vowels ratio.
  • Statistical: Shannon entropy, bigram entropy (character pair transition randomness — higher = more suspicious), digit ratio, unique character ratio.

18 allowed consonant clusters (e.g., sch, str, thr, ght, tch, nch). Clusters of 3+ consonants not containing an allowed pattern are counted as “impossible.”

Bigram and trigram frequency analysis across 7 language models (English, Spanish, French, German, Italian, Portuguese, Romanized). Scores how likely a character sequence is to appear in natural text for each language, taking the best match. Higher naturalness = lower fraud risk.

Checks whether the first-digit distribution of numbers in an email batch follows Benford’s Law (P(d) = log10(1 + 1/d)). Natural registrations follow this distribution; sequential/automated generation produces uniform distribution.

Method: chi-square goodness-of-fit test (df=8) with critical value at 0.05 significance (15.507). Requires minimum 30 digit samples for statistical validity. Used primarily in batch analysis rather than individual scoring.

Resolves the email domain’s MX records to classify the provider and detect suspicious hosting:

  • Resolution: Cloudflare DNS-over-HTTPS (cloudflare-dns.com/dns-query) with 500ms timeout via AbortController
  • Caching: In-memory with 15-minute TTL. Concurrent requests for the same domain are deduplicated via an inflight map.
  • Provider classification: Google, Microsoft, iCloud, Yahoo, Zoho, Proton, self-hosted (MX points to own domain), other. Provider is determined by the MX exchange hostname patterns.
  • Features produced: mx_has_records, mx_record_count, and one-hot encoded mx_provider_* flags (7 providers)

The ML Worker’s behavior is controlled by a config loaded from KV:

{
"riskThresholds": { "block": 0.65, "warn": 0.35 },
"actionOverride": null,
"adjustments": {
"professionalEmailFactor": 0.5,
"professionalDomainFactor": 0.5,
"professionalAbnormalityFactor": 0.6
},
"ood": { "maxRisk": 0.85, "warnZoneMin": 0.6 }
}
  • actionOverride: Set to "allow" for monitoring mode — runs all detection and logs decisions but never blocks. Critical for safe rollout of new models or config changes.
  • adjustments: Professional email patterns (name-based addresses at reputable domains) get their risk score multiplied by these factors, reducing false positives on legitimate business emails.
  • ood (out-of-distribution): When the model encounters feature vectors outside its training distribution, risk is capped at maxRisk (0.85) and flagged if above warnZoneMin (0.6). Prevents overconfident predictions on novel patterns.

A post-model safety net that can override ML decisions based on simple threshold rules. Loaded from KV (risk-heuristics.json) with 60-second cache TTL, falling back to hardcoded defaults.

HeuristicBlock ThresholdWarn ThresholdScore Offset
TLD risk0.9+0.8++0.10
Domain reputation0.95+0.85++0.08
Sequential confidence0.98+0.9++0.05
Digit ratio0.9+0.8++0.05
Plus-addressing abuse0.8++0.03

Each rule has a threshold, decision (warn or block), direction (gte or lte), and minScoreOffset. Rules are applied after model scoring — they can elevate a score but never reduce it. The KV-based config means you can add or adjust heuristics without redeploying.

interface HeuristicRule {
threshold: number;
decision: "warn" | "block";
reason: string;
direction?: "gte" | "lte"; // Default: gte
minScoreOffset?: number;
}

The model (50 trees, trained offline with scikit-learn) is serialized as JSON and stored in KV. The Worker evaluates it using iterative tree traversal (not recursive, to avoid stack limits on the edge runtime).

type CompactTreeNode =
| { t: "l"; v: number } // Leaf: fraud probability
| { t: "n"; f: string; v: number; l: CompactTreeNode; r: CompactTreeNode }; // Split node
function predictForestScore(
model: ForestModel,
features: Record<string, number>,
): number {
const maxDepth = Math.min(model.meta.config?.max_depth ?? 20, 50);
let total = 0;
for (const tree of model.forest) {
let node = tree;
let depth = 0;
// Iterative traversal (no recursion)
while (node.t === "n" && depth < maxDepth) {
const featureValue = features[node.f] ?? 0;
node = featureValue <= node.v ? node.l : node.r; // scikit-learn convention
depth++;
}
total += node.t === "l" ? node.v : 0.5; // Fallback if depth exceeded
}
return total / model.forest.length; // Average probability across trees
}

Raw forest outputs are calibrated using Platt scaling (sigmoid fit on out-of-bag predictions from training). This converts raw vote averages into well-calibrated probabilities:

calibrated = 1 / (1 + exp(-(intercept + coef * raw_score)))

Calibration parameters (intercept, coef) are stored in the model’s metadata and applied automatically during inference. The production model’s calibration was fitted on 330,139 OOB samples.

The middleware uses Random Forest as primary and Decision Tree as fallback. If both models are unavailable (KV failure), the system applies a degraded “warn floor” score and fires an ops alert. This ensures the detection pipeline never silently passes traffic unscored.

Instead of wiring fraud detection per-route, a Hono middleware runs on every POST request that contains an email field:

async function fraudDetectionMiddleware(c: Context, next: Next) {
if (c.req.method !== "POST" || c.get("skipFraudDetection")) {
return next();
}
const body = await c.req.raw
.clone()
.json()
.catch(() => null);
const email = body?.email;
if (!email) return next();
// Load model from KV, extract features, score, decide
const features = buildFeatureVector(email, c.req);
const score = predictForestScore(model, features);
const calibrated = applyPlattCalibration(score, model.meta.calibration);
const decision =
calibrated >= threshold
? "block"
: calibrated >= warnThreshold
? "warn"
: "allow";
c.set("fraudDetection", { score: calibrated, decision, signals });
return next();
}
// Register globally
app.use("/*", fraudDetectionMiddleware);

Routes that need to opt out (e.g., dashboard auth) set a context flag:

app.post("/dashboard/auth", (c) => {
c.set("skipFraudDetection", true);
// ... handle auth
});

The two Workers communicate via Cloudflare’s service bindings, which use Workers RPC built on Cap’n Proto. Per the docs, there is zero overhead — both Workers execute on the same thread of the same server.

import { WorkerEntrypoint } from "cloudflare:workers";
class FraudDetectionService extends WorkerEntrypoint<Env> {
// RPC method callable by other Workers
async validate(request: {
email: string;
consumer?: string;
flow?: string;
headers?: Record<string, string | null>;
}): Promise<ValidationResult> {
// Reconstruct an HTTP request from RPC args
// This reuses the same Hono handler for both HTTP and RPC
const httpRequest = new Request("http://internal/validate", {
method: "POST",
headers: this.buildHeaders(request.headers),
body: JSON.stringify({
email: request.email,
consumer: request.consumer,
}),
});
const response = await app.fetch(httpRequest, this.env, this.ctx);
return response.json() as Promise<ValidationResult>;
}
// Standard HTTP handler (for direct API access)
async fetch(request: Request): Promise<Response> {
return app.fetch(request, this.env, this.ctx);
}
}
export { FraudDetectionService };

Wrangler config:

{
"services": [
{
"binding": "FRAUD_DETECTOR",
"service": "markov-mail",
"entrypoint": "FraudDetectionService",
},
],
}

Calling the service:

// env.FRAUD_DETECTOR is typed as the RPC interface
const result = await env.FRAUD_DETECTOR.validate({
email: submittedEmail,
consumer: "form-worker",
flow: "submission",
headers: extractCfHeaders(request),
});

RPC requires compatibility_date of 2024-04-03 or later.


When a submission is blocked, the offending identifiers are blacklisted with escalating TTLs:

OffenseTimeoutCumulative
1st1 hour1h
2nd4 hours5h
3rd8 hours13h
4th12 hours25h
5th+24 hours49h+
function calculateProgressiveTimeout(
offenseCount: number,
config: FraudDetectionConfig,
): number {
const schedule = config.timeouts.schedule; // [3600, 14400, 28800, 43200, 86400]
const index = Math.min(offenseCount, schedule.length - 1);
return schedule[index];
}

Blacklist entries store multiple identifiers (email, ephemeral ID, JA4, IP) and are checked on the fast path before any Turnstile API call. Each subsequent hit updates last_seen_at and increments offense_count.

d2 diagram

All thresholds, weights, and detection parameters live in a centralized config with environment overrides. The override is deep-merged with defaults at every nesting level.

const DEFAULT_CONFIG = {
risk: {
mode: "defensive" as "defensive" | "additive",
blockThreshold: 70,
levels: {
low: { min: 0, max: 39 },
medium: { min: 40, max: 69 },
high: { min: 70, max: 100 },
},
corroboration: {
threshold: 30, // Min score to count as corroborating
minSignals: 3, // Signals required to trigger bonus
bonus: 15, // Flat bonus added
},
weights: {
tokenReplay: 0.28,
emailFraud: 0.14,
ephemeralId: 0.15,
validationFrequency: 0.1,
ipDiversity: 0.07,
ja4SessionHopping: 0.06,
ipRateLimit: 0.07,
headerFingerprint: 0.07,
tlsAnomaly: 0.04,
latencyMismatch: 0.02,
},
},
detection: {
ephemeralIdSubmissionThreshold: 2,
validationFrequencyBlockThreshold: 3,
ipRateLimitThreshold: 3,
},
timeouts: {
schedule: [3600, 14400, 28800, 43200, 86400],
maximum: 86400,
},
};

Override via FRAUD_CONFIG environment variable (partial objects merge with defaults):

wrangler.jsonc
{
"vars": {
"FRAUD_CONFIG": {
"risk": {
"blockThreshold": 60,
"weights": { "emailFraud": 0.2 },
},
},
},
}

After merging, weights are auto-normalized to sum to 1.0 if the override creates an imbalance.


Minimal binding configuration for the two-Worker system:

// Form Worker (forminator) wrangler.jsonc
{
"name": "form-worker",
"compatibility_date": "2024-10-11",
"d1_databases": [
{
"binding": "DB",
"database_name": "form-submissions",
},
],
"services": [
{
"binding": "FRAUD_DETECTOR",
"service": "ml-email-worker",
"entrypoint": "FraudDetectionService",
},
],
"kv_namespaces": [{ "binding": "FORM_CONFIG" }],
"assets": {
"binding": "ASSETS",
"directory": "./frontend/dist",
},
}
// ML Worker (markov-mail) wrangler.jsonc
{
"name": "ml-email-worker",
"compatibility_date": "2024-10-11",
"d1_databases": [
{
"binding": "DB",
"database_name": "email-validations",
},
],
"kv_namespaces": [
{ "binding": "CONFIG" },
{ "binding": "DISPOSABLE_DOMAINS_LIST" },
{ "binding": "TLD_LIST" },
],
"triggers": {
"crons": ["0 */6 * * *"],
},
}

TablePurposeKey columns
submissionsForm data + 42 metadata fieldsemail, ephemeral_id, risk_score_breakdown, ja4, bot_score
turnstile_validationsEvery validation attempttoken_hash (UNIQUE), detection_type, risk_score_breakdown
fraud_blacklistProgressive mitigation cacheidentifier_type, identifier_value, expires_at, offense_count
fraud_blocksPre-Turnstile forensic logdetection_type, fraud_signals_json
fingerprint_baselinesTLS fingerprint anomaly baselinesja4_bucket, asn_bucket, hit_count
TablePurpose
validationsEvery email validation with all signals and features
training_metricsModel training history and accuracy
ab_test_metricsExperiment tracking for model variants
admin_metricsConfiguration change audit log

The ML Worker uses a cron trigger (every 6 hours) to update its disposable domain list from external sources:

export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext,
) {
ctx.waitUntil(updateDisposableDomains(env));
},
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
return app.fetch(request, env, ctx);
},
};

The ScheduledController provides controller.cron (the matching expression) and controller.scheduledTime. Use ctx.waitUntil() for background work that should complete after the handler returns.


The Random Forest is trained offline using a Python/CLI toolchain, then deployed to KV for edge inference. The pipeline runs outside the Worker runtime.

SourceRowsLabel
Enron email corpus (cleaned)172,806legit
Synthetic (legit filler)327,194legit
Synthetic (fraud)500,000fraud
Total1,000,00050/50 balanced

Synthetic data is generated with deterministic seeds for reproducibility. Fraud patterns include sequential, dated, high-entropy, disposable domain, and plus-addressing variants. The Enron corpus provides real-world legitimate email diversity.

cli/commands/
model/ train_forest.py, calibrate.ts, pipeline.ts, guardrail.ts
features/ export.ts (feature vector CSV generation)
data/ synthetic.ts, clean_enron.ts, domains.ts
deploy/ deploy.ts, status.ts
ab/ create.ts, analyze.ts, status.ts, stop.ts
test/ api.ts, batch.ts, cron.ts, detectors.ts
d2 diagram

Step 1: Feature export — Extract 45-feature vectors from the canonical dataset:

Terminal window
npm run cli features:export -- --input data/main.csv --output tmp/features.csv

Step 2: Train — scikit-learn RandomForestClassifier with conflict-zone weighting:

Terminal window
python cli/commands/model/train_forest.py \
--input tmp/features.csv \
--output config/production/random-forest.json \
--n-trees 50 --max-depth 6 --min-samples-leaf 20 \
--conflict-weight 20.0 --no-split

Key training parameters:

  • Conflict-zone weighting: Samples with bigram_entropy > 3.0 AND domain_reputation_score >= 0.6 get 20x weight. These are the overlap-region cases where legitimate and fraudulent emails look similar — forcing the forest to learn deeper patterns in this zone.
  • --no-split: Trains on 100% of data for production (uses OOB predictions for calibration instead of a held-out set).
  • Platt calibration: Fits LogisticRegression on OOB predictions to produce well-calibrated probabilities. Outputs intercept and coef stored in model metadata.

Step 3: Guardrails — Validate model before deployment (accuracy, size under KV 25MB limit, feature alignment).

Step 4: Deploy — Upload model JSON to KV. The Worker picks it up within 60 seconds (KV eventual consistency).

The serialized model contains metadata for runtime validation:

{
"meta": {
"version": "3.1.0-forest",
"features": ["avg_segment_length", "bigram_entropy", "digit_ratio", "..."],
"tree_count": 50,
"feature_importance": {
"domain_reputation_score": 0.203,
"provider_is_disposable": 0.185,
"digit_ratio": 0.068,
"provider_is_free": 0.059,
"name_similarity_score": 0.048
},
"calibration": {
"method": "platt",
"intercept": -6.2006,
"coef": 13.2447,
"samples": 330139
},
"config": {
"n_trees": 50,
"max_depth": 6,
"min_samples_leaf": 20,
"conflict_weight": 20.0
}
},
"forest": ["...50 serialized decision trees..."]
}

Top 5 features by importance: domain reputation (0.203), disposable domain flag (0.185), digit ratio (0.068), free provider flag (0.059), name-email similarity (0.048). Geo features (language/timezone mismatch) have zero importance in the current model — they contribute to heuristic overrides instead.


The ML Worker includes a fully implemented A/B testing framework for comparing model variants. Experiments are configured in KV and use consistent hash-based traffic splitting.

interface ABTestConfig {
experimentId: string;
description: string;
variants: {
control: { weight: number; config?: Partial<FraudDetectionConfig> };
treatment: { weight: number; config?: Partial<FraudDetectionConfig> };
};
startDate: string; // ISO 8601
endDate: string;
enabled: boolean;
metadata?: {
hypothesis: string;
expectedImpact: string;
successMetrics: string[];
};
}

Variant assignment uses the first 8 hex characters of the request fingerprint hash, converted to a bucket (0-99). Buckets below the treatment weight go to treatment, the rest to control. This ensures the same device consistently sees the same variant.

function getVariant(
fingerprintHash: string,
config: ABTestConfig,
): "control" | "treatment" {
const bucket = parseInt(fingerprintHash.substring(0, 8), 16) % 100;
return bucket < config.variants.treatment.weight ? "treatment" : "control";
}

Variant-specific config overrides are deep-merged with the base config, so experiments can change thresholds, weights, or feature flags independently. Weights must sum to 100.

Terminal window
npm run cli ab:create # Create and upload experiment config to KV
npm run cli ab:status # Check active experiment status
npm run cli ab:analyze # Compare variant performance from D1 metrics
npm run cli ab:stop # End experiment and remove from KV

The ML Worker includes an Astro + React analytics dashboard for monitoring fraud detection in production:

  • Metrics grid: Key KPIs (total validations, block rate, warn rate, avg score)
  • Time-series charts: Score distributions and decision trends over time
  • Block reasons breakdown: Which heuristics and model signals trigger blocks
  • Validation table: Drill into individual validation results with full signal details
  • Model comparison: Side-by-side A/B test variant performance
  • Query builder: Ad-hoc SQL queries against the D1 metrics tables
  • System status: Health indicators for model availability, KV connectivity, cron job status

Authentication uses HMAC-signed session cookies with 24-hour TTL and timing-safe comparison.


PrincipleImplementation
Fail-open by defaultSignal collection, ML scoring, blacklist writes — all return safe baselines on error
Fail-secure for definitivesToken replay and Turnstile validation block on error (assume reused/invalid)
Weight redistributionInactive signals don’t suppress the score — their weight shifts to active ones
Transparent decisionsEvery score stores a full ScoringDecision audit trail with each adjustment step
Per-request tracingA unique request ID generated at entry threads through all DB writes for forensic correlation
Config-driven thresholdsAll magic numbers live in a mergeable config with auto-normalization for weights
Progressive mitigationEscalating timeouts instead of permanent bans — reduces false positive impact
Single code pathRPC entrypoint constructs a synthetic HTTP request through the same Hono handler as direct API calls
Monitoring modeAction override flag runs all detection and logs decisions without blocking — safe rollout for new rules

TaskSteps
Add a detection layer1. Implement signal collection function (return baseline on error) 2. Add score component + weight to config 3. Wire into Phase 2 of submission pipeline 4. Add normalization function in scoring module
Add an ML feature1. Implement extractor in feature module 2. Add to feature vector builder 3. Run features:export to regenerate CSV 4. Retrain with train_forest.py 5. Run guardrails 6. Deploy model JSON to KV (no Worker redeploy)
Tune thresholds1. Set mode: 'additive' to disable deterministic overrides 2. Analyze score distributions from stored breakdowns via dashboard 3. Adjust FRAUD_CONFIG in wrangler vars 4. Switch back to mode: 'defensive'
Run an A/B test1. ab:create with hypothesis, traffic split, and variant config overrides 2. Monitor via dashboard model comparison view 3. ab:analyze to compare variant metrics 4. ab:stop and promote winner
Add a heuristic rule1. Add rule to risk-heuristics.json (threshold, decision, reason, offset) 2. Upload to KV 3. Rule takes effect within 60 seconds (no redeploy)
Safe rollout1. Set actionOverride: "allow" in production config 2. Deploy new model or config 3. Monitor decisions in dashboard (logs without blocking) 4. Remove override when confident