Building a Fraud-Prevention CF Worker: Architecture Deep-Dive
by AbuseGraph Engineering
Overview
The AbuseGraph edge worker is a Cloudflare Worker built with Hono v4. It runs on Cloudflare's edge network (300+ locations) and handles four routes:
POST /v1/risk/evaluate — Browser SDK → edge (fast, no PII)
POST /v1/risk/signal — Consumer backend → edge (authoritative)
POST /v1/turnstile/verify — Turnstile siteverify proxy
GET /v1/admin/review/* — Admin console (internal-token-gated)
Route Structure
// src/app.ts
import { Hono } from "hono"
import { useInternalAuth } from "./middleware/internal-auth"
export function createApp() {
const app = new Hono<{ Bindings: Env }>()
app.get("/health", (c) => c.json({ status: "ok" }))
// Browser SDK → edge
app.post("/v1/risk/evaluate", async (c) => {
const cf = c.req.raw.cf
return handleEvaluate(c.req.raw, c.env, cf)
})
// Server-to-server (requires internal auth)
app.post("/v1/risk/signal", async (c) => {
const auth = useInternalAuth(
c.req.raw.headers,
c.env.FRAUD_SERVICE_TOKEN,
"server"
)
if (!auth.authenticated) return c.json({ error: "unauthorized" }, 401)
return handleSignal(c.req.raw, c.env, cf)
})
return app
}
Internal Auth Middleware
All server-to-server routes require X-AbuseGraph-Key and X-Request-Source headers. The key is compared using constant-time comparison to prevent timing attacks:
import { timingSafeEqual } from "node:crypto"
export function secureCompare(a: string, b: string): boolean {
if (a.length === 0 || b.length === 0) return false
const bufA = Buffer.from(a)
const bufB = Buffer.from(b)
if (bufA.length !== bufB.length) return false
return timingSafeEqual(bufA, bufB)
}
Scoring Engine
The scoring engine is rule-based v1 (SCORING_MODEL_VERSION in @palisade/shared). Each fired risk signal contributes its catalog weight as absolute severity; value is enrichment only. The final score is the sum of weights, capped at 100:
export function computeScore(signals: RiskSignal[]): number {
if (signals.length === 0) return 0
const total = signals.reduce((sum, s) => sum + Math.max(s.weight, 0), 0)
return Math.min(100, Math.round(total))
}
export function scoreToVerdict(score: number): Verdict {
if (score <= 30) return "allow" // 0–30
if (score <= 60) return "challenge" // 31–60
if (score <= 80) return "shadow" // 61–80
return "block" // 81–100
}
Every scored type lives in SIGNAL_CATALOG (weight, category, research source, API path).
Verdict Token (HMAC)
The verdict token bridges Call 1 (browser) and Call 2 (backend):
export function signVerdictToken(
payload: Omit<VerdictTokenPayload, "exp">,
hmacSecret: string
): VerdictToken {
const fullPayload = { ...payload, exp: Date.now() + 5 * 60 * 1000 }
const payloadB64 = Buffer.from(JSON.stringify(fullPayload))
.toString("base64url")
const sig = createHmac("sha256", hmacSecret)
.update(payloadB64)
.digest("base64url")
return { token: `${payloadB64}.${sig}`, expiresAt: new Date(fullPayload.exp).toISOString() }
}
Identity Graph
// Recursive walk over nodes/edges — circuit-breakered
export async function findLinkedNodes(nodeId: string, maxHops: number) {
if (isCircuitOpen()) return { linkedNodes: [], linkedToAbuser: false }
try {
return await queryLinkedNodes(nodeId, maxHops)
} catch {
tripCircuitBreaker() // short cooldown
return { linkedNodes: [], linkedToAbuser: false }
}
}
Velocity
// Sliding window: INCR + EXPIRE
const commands = [
["INCR", `vel:fp:${fingerprintHash}`],
["EXPIRE", `vel:fp:${fingerprintHash}`, "3600"],
["INCR", `vel:ip:${ip}`],
["EXPIRE", `vel:ip:${ip}`, "3600"],
]
Scheduled Handler
The worker runs a daily cron to sync the datacenter ASN blocklist:
export default {
async scheduled(controller: ScheduledController, env: Env) {
const count = await syncAsnBlocklist(env.DATACENTER_ASN)
console.log(`ASN blocklist synced: ${count} entries`)
}
}
Wrangler Configuration
{
"name": "abusegraph-edge",
"main": "src/index.ts",
"compatibility_flags": ["nodejs_compat"],
"kv_namespaces": [{ "binding": "DATACENTER_ASN", "id": "..." }],
"triggers": { "crons": ["0 0 * * *"] }
}
Conclusion
The architecture is deliberately simple: Hono routes → lib modules → external clients. Each module has a single responsibility. Circuit breakers protect against external failures. The two-call model keeps PII out of the browser path. The scoring engine is transparent and auditable.