Bot traffic hits your origin before traditional server-side detection can stop it. By the time your API route validates a request, the connection is established, bandwidth is consumed, and your rate limiters are already tracking the abuse. Cloudflare Workers offers a better approach: verify bot status at the edge, in milliseconds, before the request ever reaches your origin.
This guide walks through deploying bot detection to Cloudflare Workers using Device.AI, with complete working examples for common patterns.
Why Edge Bot Detection Matters
Latency
Cloudflare Workers run on servers within milliseconds of users globally. A bot-detection API call to device.ai from a Cloudflare Worker completes in 5-20ms (not 100-200ms from your distant origin server). For a visitor in Tokyo hitting your US origin, this saves 100-150ms of latency before the request is even validated.
Origin Protection
Malicious traffic never reaches your origin. That means:
- Your rate limiters don't get congested with bot traffic
- Your database isn't queried for verification of bots
- Your infrastructure costs don't spike during attacks
- Your logs stay clean — bot requests don't pollute your analytics
Scale Without Cost
Cloudflare Workers scale horizontally across 300+ data centers. Processing millions of bot-detection requests per day from the edge costs significantly less than processing them on your origin infrastructure.
Architecture: How It Works
┌──────────────────────────────────────────────────────┐
│ Visitor Request │
└──────────────────────────────────┬──────────────────┘
│
v
┌──────────────────────┐
│ Cloudflare Workers │
│ (Bot Detection) │
└──────────────────────┘
│
┌──────────────────┴──────────────┐
│ Call Device.AI API │
│ Score: 0.15 (bot) │
v v
✗ BLOCK ✓ ALLOW
403 Forbidden │
(never hits origin) v
┌──────────────────────┐
│ Your Origin │
│ (App Server) │
└──────────────────────┘
Step 1: Create Your Worker
// wrangler.toml
name = "bot-detection-worker"
main = "src/index.ts"
compatibility_date = "2026-07-01"
[env.production]
route = "example.com/*"
zone_id = "YOUR_ZONE_ID"
[[env.production.env]]
vars = { DEVICE_AI_KEY = "dv_live_YOUR_KEY" }
Step 2: Write the Bot Detection Handler
// src/index.ts
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
// Get visitor's IP and User-Agent
const ip = request.headers.get('CF-Connecting-IP') || '0.0.0.0';
const userAgent = request.headers.get('User-Agent') || '';
// Call Device.AI to verify
const verifyRes = await fetch('https://device.ai/v1/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: env.DEVICE_AI_KEY,
userAgent,
ip,
}),
});
const verification = await verifyRes.json() as {
score: number;
bot: boolean;
risk: string;
};
// Block obvious bots
if (verification.score < 0.3 || verification.bot) {
return new Response('Access Denied', { status: 403 });
}
// Continue to origin for legitimate traffic
return fetch(request);
},
};
interface Env {
DEVICE_AI_KEY: string;
}
Step 3: Deploy
npx wrangler deploy --env production
That's it. Your Worker is now live, verifying every request at the edge.
Pattern 1: Route-Specific Protection
Not every route needs the same protection level. API endpoints and form submissions are high-risk. Public content (blog posts, marketing pages) can be more permissive.
// src/index.ts
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
const ip = request.headers.get('CF-Connecting-IP') || '0.0.0.0';
const userAgent = request.headers.get('User-Agent') || '';
// High-risk routes: strict verification
const strictRoutes = ['/api/', '/checkout/', '/login/', '/signup/'];
const threshold = strictRoutes.some(r => url.pathname.startsWith(r))
? 0.3 // Strict: block any suspicious score
: 0.1; // Permissive: only block obvious bots
const verification = await (await fetch('https://device.ai/v1/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: env.DEVICE_AI_KEY, userAgent, ip }),
})).json() as { score: number; bot: boolean };
if (verification.score < threshold) {
return new Response('Access Denied', { status: 403 });
}
return fetch(request);
},
};
interface Env {
DEVICE_AI_KEY: string;
}
Pattern 2: Graceful Fallback (Circuit Breaker)
Device.AI is highly available, but the internet is unpredictable. If the verification API times out or fails, you have three options: fail open (allow the request), fail closed (block it), or allow but log for manual review. Here's a fail-open approach:
// src/index.ts
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
const ip = request.headers.get('CF-Connecting-IP') || '0.0.0.0';
const userAgent = request.headers.get('User-Agent') || '';
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2s timeout
const verifyRes = await fetch('https://device.ai/v1/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: env.DEVICE_AI_KEY, userAgent, ip }),
signal: controller.signal,
});
clearTimeout(timeoutId);
const verification = await verifyRes.json() as { score: number; bot: boolean };
if (verification.score < 0.3 || verification.bot) {
// Log the fraudulent click
await env.BLOCKED_IPS.put(
`${Date.now()}_${ip}`,
JSON.stringify({ ip, userAgent, score: verification.score })
);
return new Response('Access Denied', { status: 403 });
}
} catch (error) {
// Verification API failed. Log and allow (fail-open).
console.error('Device.AI verification failed:', error);
await env.FAILED_VERIFICATIONS.put(
`${Date.now()}_${ip}`,
`Verification failed: ${error}`
);
// Continue to origin anyway
}
return fetch(request);
},
};
interface Env {
DEVICE_AI_KEY: string;
BLOCKED_IPS: R2Bucket;
FAILED_VERIFICATIONS: KVNamespace;
}
Pattern 3: Adaptive Scoring by Request Type
Different request types have different false-positive costs. A rejected login attempt is annoying; a rejected form submission is a lost lead. Adjust thresholds dynamically:
// src/index.ts
const requestTypeThresholds = {
'api/payments': 0.5, // Payments: strict (false positives are rare)
'api/login': 0.4, // Login: strict (can use password reset)
'api/contact': 0.2, // Forms: permissive (cost of FP is high)
'public/': 0.05, // Public pages: almost never block
};
function getThreshold(pathname: string): number {
for (const [pattern, threshold] of Object.entries(requestTypeThresholds)) {
if (pathname.startsWith(pattern)) return threshold;
}
return 0.3; // default
}
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
const ip = request.headers.get('CF-Connecting-IP') || '0.0.0.0';
const userAgent = request.headers.get('User-Agent') || '';
const threshold = getThreshold(url.pathname);
const verification = await (await fetch('https://device.ai/v1/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: env.DEVICE_AI_KEY, userAgent, ip }),
})).json() as { score: number; bot: boolean };
if (verification.score < threshold) {
return new Response('Access Denied', { status: 403 });
}
return fetch(request);
},
};
interface Env {
DEVICE_AI_KEY: string;
}
Pattern 4: Caching for Cost Optimization
The same user agent from the same IP range likely has the same bot status for hours. Cache the result in Cloudflare KV to save API calls on high-traffic sites:
// src/index.ts
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
const ip = request.headers.get('CF-Connecting-IP') || '0.0.0.0';
const userAgent = request.headers.get('User-Agent') || '';
const cacheKey = `device:${ip}:${userAgent}`;
// Check KV cache first
let verification = await env.CACHE.get(cacheKey).then(v => v ? JSON.parse(v) : null);
if (!verification) {
// Cache miss — call Device.AI
const res = await fetch('https://device.ai/v1/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: env.DEVICE_AI_KEY, userAgent, ip }),
});
verification = await res.json();
// Cache for 6 hours (21600 seconds)
await env.CACHE.put(cacheKey, JSON.stringify(verification), { expirationTtl: 21600 });
}
if (verification.score < 0.3 || verification.bot) {
return new Response('Access Denied', { status: 403 });
}
return fetch(request);
},
};
interface Env {
DEVICE_AI_KEY: string;
CACHE: KVNamespace;
}
Pattern 5: Rate Limiting Based on Risk Score
Instead of simply blocking bots, rate-limit them based on their risk score. Legitimate users get high limits; suspicious traffic gets strict limits:
// src/index.ts
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
const ip = request.headers.get('CF-Connecting-IP') || '0.0.0.0';
const userAgent = request.headers.get('User-Agent') || '';
const verification = await (await fetch('https://device.ai/v1/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: env.DEVICE_AI_KEY, userAgent, ip }),
})).json() as { score: number };
// Determine rate limit tier based on score
const tier = verification.score > 0.7
? { requests: 1000, window: 60 } // Clean traffic
: verification.score > 0.3
? { requests: 100, window: 60 } // Suspicious
: { requests: 5, window: 60 }; // Likely bot
// Apply rate limiting (simplified — use Durable Objects in production)
const countKey = `rl:${ip}`;
const count = await env.KV.get(countKey).then(v => parseInt(v || '0') + 1);
await env.KV.put(countKey, count.toString(), { expirationTtl: tier.window });
if (count > tier.requests) {
return new Response('Rate Limited', { status: 429 });
}
return fetch(request);
},
};
interface Env {
DEVICE_AI_KEY: string;
KV: KVNamespace;
}
Monitoring and Alerting
Log all bot blocks to R2 and set up Cloudflare Logpush to monitor trends:
// Send logs to R2
const blocked = {
timestamp: Date.now(),
ip,
userAgent,
score: verification.score,
path: url.pathname,
};
await env.LOGS.put(
`blocked/${new Date().toISOString().split('T')[0]}/${ip}_${Date.now()}`,
JSON.stringify(blocked)
);
Then query trends with SQL on R2:
-- SQL query on R2
SELECT
DATE(timestamp) as day,
COUNT(*) as blocks,
COUNT(DISTINCT ip) as unique_ips,
AVG(score) as avg_score
FROM s3_object_lambda_table
WHERE path LIKE 'blocked/%'
GROUP BY DATE(timestamp)
ORDER BY day DESC;
Cost Analysis
Cloudflare Workers include 100K free requests/day. Device.AI's free tier includes 1,000 verifications/day. For a site with 50K requests/day and 5K unique visitors:
- Cloudflare cost: $0 (under free tier)
- Device.AI cost: ~500 verifications (under free tier if unique visitors + margin)
- Total: $0 for both
Once you exceed free tiers:
- Cloudflare: $0.15 per 100K requests (Bundled Plan) or $0.30 (Pay-as-you-go)
- Device.AI: $0.001 per verification (generous overage pricing)
For 1M verifications/month, that's $1,000/month in Workers cost + $1,000/month in Device.AI cost = $2,000/month to protect your entire infrastructure. Most sites will fall well below this.
Getting Started
- Get a Device.AI key: Visit device.ai and click "Get Free API Key" (no signup required)
- Install Wrangler: `npm install -g wrangler`
- Create a Worker: `wrangler init my-bot-detection`
- Copy one of the patterns above into `src/index.ts`
- Add your Device.AI key: `wrangler secret put DEVICE_AI_KEY`
- Deploy: `wrangler deploy`
You now have bot detection running at the Cloudflare edge, protecting your entire origin from malicious traffic — with <1ms detection latency and zero cost for the first 100K requests per day.