← BACK TO BLOG
[Engineering]2026-07-1010 min

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.