Inline WAF on Workers.
GA@tillstack/shield-cloudflare evaluates every request inside your own Worker — TillDev-managed rules plus the cross-customer threat-intel deny list — using Cloudflare's native signals (CF-Connecting-IP, request.cf.country). Config is cached per isolate; decision reports flush via ctx.waitUntil so they never delay a response.
Two lines in front of your routes
pnpm add @tillstack/shield-cloudflare
wrangler secret put TILLSHIELD_EDGE_KEY # tse_… from your Shield projectimport { createShield, type ShieldCloudflare } from '@tillstack/shield-cloudflare'
// Isolate scope — one engine per Worker instance, config cached across requests.
let shield: ShieldCloudflare | null = null
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
shield ??= createShield({
edgeKey: env.TILLSHIELD_EDGE_KEY, // secret binding — never a var
kv: env.SHIELD_RATELIMIT, // optional: KV namespace for distributed rate limits
})
const blocked = await shield.handle(req, ctx)
if (blocked) return blocked // block/challenge answered here
// …your routes
return new Response('ok')
},
}Prefer wrapping? shield.wrap(handler) produces a fetch handler with the same behavior: shield first, your code only when the request is allowed.
tilldev pulse projects create <name> --platform web, then tilldev shield edge-keys create --project <that id>(or dashboard → Shield → Edge keys).What actually runs per request
- No per-request fetch to TillDev. Rules and threat-intel are pulled once per isolate and refreshed in the background(stale-while-revalidate, on by default in this wrapper; 30s floor, tunable with
minRefreshSeconds). The hot path always evaluates in memory — passctxso the refresh survives past the response. - Reports never block. Non-allow decisions queue and flush through
ctx.waitUntilafter the response is sent.
Failure modes, stated plainly
- Control plane unreachable, config already fetched: the last-good ruleset keeps enforcing, indefinitely, with zero added request latency. Background refresh retries keep failing quietly until the plane returns; your uptime never depends on ours.
- Before the first successful fetch (fresh isolate during an outage): default fail-open — traffic serves unprotected rather than down. Set
failMode: 'closed'to refuse traffic (503) instead, if an unprotected surface is worse than an unavailable one. - Decision-path errors (e.g. the KV rate-limiter rejecting mid-request) honor the same switch: fail-open allows, fail-closed blocks with a 503.
Rate limiting that survives isolates
Without kv, rate limits count per isolate — fine for coarse protection, but a burst spread across many isolates undercounts. Bind a KV namespace and the package's KvRateLimiter makes the counters distributed:
# wrangler.toml
[[kv_namespaces]]
binding = "SHIELD_RATELIMIT"
id = "…"Challenge with TillGate, not a bare 403
Pass the tillgate option and a challenge decision serves an interstitial human-check instead of a flat status. Once the visitor clears it, a pass cookie (signed with your edge key) lets them through until it expires — the verify callback is handled inside shield.handle(). See TillGate for the check itself.
Where the edge key lives
tse_… authenticates config fetches and decision reports for your project — keep it in a Worker secret (wrangler secret put), or inject it at deploy time from TillSecrets the same way you handle the rest of your config.