TILLSHIELD · EDGE

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.

01Wire

Two lines in front of your routes

bash
pnpm add @tillstack/shield-cloudflare
wrangler secret put TILLSHIELD_EDGE_KEY   # tse_… from your Shield project
ts
import { 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.

Where the key comes from
Edge keys are minted per project — the same org-level project every product shares, not a Shield-specific one. No project yet? Create one first, then mint: tilldev pulse projects create <name> --platform web, then tilldev shield edge-keys create --project <that id>(or dashboard → Shield → Edge keys).
02Runtime

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 — pass ctx so the refresh survives past the response.
  • Reports never block. Non-allow decisions queue and flush through ctx.waitUntil after the response is sent.
03Honesty

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.
04Limits

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:

toml
# wrangler.toml
[[kv_namespaces]]
binding = "SHIELD_RATELIMIT"
id = "…"
05Human check

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.

06Hygiene

Where the edge key lives

Secret, not var
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.