Worker security: secrets, CSP, Bot Management, Turnstile

Defense-in-depth for Cloudflare Workers: WAF + Bot Management, Turnstile, Access JWT, secret management, CSP/HSTS, 4 auth patterns, Zod validation, and anti-patterns to avoid.

· 9 min read · Đọc bản tiếng Việt
Six-layer defense-in-depth for Workers: WAF + Bot Management at the edge, Turnstile on forms, Access JWT for admin, wrangler secrets, CSP/HSTS headers, Zod input validation, and 4 auth patterns (Access, signed cookie, API key, OIDC)

TL;DR

Workers aren’t dedicated servers. No host firewall, no systemd, no selinux. Security follows a 6-layer defense-in-depth model, from the edge inward:

  1. WAF + Bot Management — edge, before the Worker. OWASP, rule-based, bot score.
  2. Rate limit + Turnstile — request quotas, invisible captcha for forms.
  3. Cloudflare Access — zero-trust JWT for admin routes.
  4. Worker auth + validate — verify JWT/cookie, Zod input schema.
  5. Response headers — CSP, HSTS, X-Frame-Options, Referrer-Policy.
  6. Storage bindings — D1/KV/R2 scoped per Worker, no public internet.

Main thesis:

Worker security isn’t about “writing secure handlers”. Cloudflare provides many platform-level controls (WAF, Bot, Access, Turnstile) that are cheaper and safer than coding them yourself. Teams that skip these layers usually end up rebuilding an inferior version in the Worker. A production app needs both platform controls and app-level checks.

This post covers: all 6 layers with real config, 4 auth patterns (Access, signed cookie, API key, OIDC), proper secret management, a minimal CSP header, Bot Management rules, and 8 anti-patterns.


Who this is for

  • Developers who just deployed a Worker and haven’t thought much about security.
  • Teams with admin dashboards who need bot protection.
  • Anyone managing secrets through .env who wants to switch to wrangler secrets.

Recommended prerequisites: Part 3 (bindings), Part 17 (observability).

By the end of this post you will:

  • Set up Access for admin routes in under 15 minutes.
  • Implement a signed cookie session for app users.
  • Deploy a minimal CSP header without breaking existing pages.
  • Know when Bot Management is worth enabling, and when Turnstile is enough.

What this post isn’t about

  • OWASP top 10 in detail: assumes you know XSS, SQL injection, CSRF. Focus is Worker-specific controls.
  • Deep enterprise WAF custom rule language: deserves its own post. Focus is presets + common patterns.
  • Pen-testing / offensive: this is defensive.
  • Compliance (SOC2, ISO 27001): organizational-level, not covered.

6-layer defense-in-depth

6 layers of Worker defense, from outer to inner: Cloudflare WAF/Bot Management, Rate limit + Turnstile, Access JWT for admin, Worker secrets + Zod input validation, CSP/HSTS security headers, storage bindings D1/KV/R2 scoped per Worker.

You don’t need all 6. A static blog only needs layer 5 (headers). A full UGC app needs all of 1-6.


Layer 1: WAF + Bot Management

WAF managed rules

Cloudflare WAF includes presets (OWASP Core Rule Set) that block SQL injection, XSS, LFI, RFI patterns.

Enabled by default on every zone (Free + Paid).

Dashboard: SecurityWAFManaged Rules. Tune sensitivity (low/medium/high).

WAF custom rules

For specific cases:

(http.request.uri.path contains "/wp-admin" and ip.geoip.country ne "VN")
→ Block

(http.request.uri.path eq "/api/expensive" and rate(1m) > 10)
→ Challenge

(http.user_agent contains "sqlmap")
→ Block

Rule Language is powerful. Combine fields across IP, UA, path, headers, body.

Bot Management

Cloudflare computes a “bot score” 0-99 per request from:

  • JA3/JA4 TLS fingerprint.
  • Behavioral patterns.
  • IP reputation.
  • HTTP/2 fingerprinting.

Rule by score:

(cf.bot_management.score < 30) → Block
(cf.bot_management.score < 60) → Challenge (Managed Challenge)

Enterprise feature, not free. Use when:

  • E-commerce: anti-scraping, anti-credential-stuffing.
  • Public dev tools: anti-abuse on the free tier.
  • Content sites: block content-theft bots.

Free alternative: Super Bot Fight Mode (Pro+), rule-based without a score.


Layer 2: Rate limit + Turnstile

Native rate limit (zone-level)

Dashboard: SecurityWAFRate limiting rules.

If: http.request.uri.path eq "/api/subscribe"
Rate: 5 requests / 1 minute per IP
Action: Block for 10 minutes

Pro plan: 10 rules. Business+: unlimited.

Free alternative: WAF custom rule with the rate() function (limited).

Custom rate limit with Durable Objects

If you need complex logic (sliding window, per-user, per-endpoint) → DO rate limiter (Part 15).

Turnstile

Invisible captcha for forms, a replacement for hCaptcha/reCAPTCHA.

Client:

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" defer></script>
<form action="/api/contact" method="POST">
  <input name="email" type="email" required>
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
  <button>Submit</button>
</form>

Turnstile injects a hidden cf-turnstile-response field with a token.

Worker verify:

async function verifyTurnstile(token: string, ip: string, secret: string): Promise<boolean> {
  const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      secret,
      response: token,
      remoteip: ip,
    }),
  });
  const { success } = await response.json();
  return success;
}

export default {
  async fetch(request: Request, env: Env) {
    const formData = await request.formData();
    const token = formData.get("cf-turnstile-response") as string;
    const ip = request.headers.get("cf-connecting-ip") ?? "";

    if (!await verifyTurnstile(token, ip, env.TURNSTILE_SECRET)) {
      return new Response("Captcha failed", { status: 403 });
    }

    // Process form
  },
};

Turnstile is free with no limit. Use for:

  • Contact forms.
  • Newsletter signup.
  • Login pages (in front of password).
  • Rate-limited public APIs.

Layer 3: Cloudflare Access

Zero-trust auth for admin routes. SSO with Google/Okta/GitHub, JWT injected into the request.

Setup

Dashboard: Zero TrustAccessApplicationsAdd.

Config:

  • Application type: self-hosted.
  • Application domain: cloudsecop.net/admin (path-specific).
  • Session duration: 24h.
  • Identity provider: Google Workspace / Okta / GitHub.
  • Policy: email ends with @mycompany.com.

User visits /admin/* → redirected to Access login → SSO → JWT in CF_Authorization cookie.

Verify in the Worker

import { parseJWT, verifyAccess } from "./access";

export default {
  async fetch(request: Request, env: Env) {
    const url = new URL(request.url);

    if (url.pathname.startsWith("/admin/")) {
      const jwt = request.headers.get("cf-access-jwt-assertion")
        ?? parseCookie(request, "CF_Authorization");

      if (!jwt) return new Response("Unauthorized", { status: 401 });

      try {
        const claims = await verifyAccess(jwt, env.CF_ACCESS_AUD, env.CF_TEAM);
        // claims.email, claims.sub, claims.country
        return handleAdmin(request, env, claims);
      } catch {
        return new Response("Invalid token", { status: 403 });
      }
    }

    return handlePublic(request, env);
  },
};

verifyAccess fetches JWKS from Cloudflare and verifies the signature:

// access.ts
import { jwtVerify, createRemoteJWKSet } from "jose";

export async function verifyAccess(token: string, aud: string, team: string) {
  const JWKS = createRemoteJWKSet(
    new URL(`https://${team}.cloudflareaccess.com/cdn-cgi/access/certs`)
  );

  const { payload } = await jwtVerify(token, JWKS, {
    issuer: `https://${team}.cloudflareaccess.com`,
    audience: aud,
  });

  return payload;  // { email, sub, country, iat, exp, ... }
}

The audience (AUD) tag identifies the app. Get it from the Access application config.

Access JWT flow

4 auth patterns for Workers: Access JWT (zero-trust SSO for admin), Signed cookie HMAC (session for regular app users), API key + scoped token (machine-to-machine), OIDC federation (Worker to AWS STS via Access).

Service Auth tokens

Machine-to-machine calls to Access-protected endpoints: use Service Tokens.

Dashboard → Access → Service Auth → Create Service Token. Get CF-Access-Client-Id + CF-Access-Client-Secret.

External service call:

curl https://cloudsecop.net/admin/api \
  -H "CF-Access-Client-Id: <client-id>" \
  -H "CF-Access-Client-Secret: <client-secret>"

Cloudflare verifies and forwards the request. Worker receives the same JWT pattern.


Layer 4: Worker auth + validate

Pattern 1: Cloudflare Access (already covered)

User-facing apps (blog commenters, shop buyers) don’t fit Access. HMAC-signed session cookies instead.

import { sign, verify } from "./cookie";

async function login(email: string, password: string, env: Env) {
  // verify password from D1
  const user = await env.DB.prepare("SELECT id, password_hash FROM users WHERE email = ?")
    .bind(email).first();
  
  if (!user || !await argon2Verify(password, user.password_hash)) {
    throw new Error("Invalid credentials");
  }

  // Create session
  const session = {
    userId: user.id,
    exp: Math.floor(Date.now() / 1000) + 86400 * 7,  // 7 days
  };

  const token = await sign(JSON.stringify(session), env.COOKIE_SECRET);

  return new Response("OK", {
    headers: {
      "Set-Cookie": `sess=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800`,
    },
  });
}

async function middleware(request: Request, env: Env) {
  const cookie = request.headers.get("Cookie");
  const token = parseCookie(cookie, "sess");
  if (!token) return null;

  try {
    const data = await verify(token, env.COOKIE_SECRET);
    const session = JSON.parse(data);
    if (session.exp < Date.now() / 1000) return null;  // expired
    return session;
  } catch {
    return null;
  }
}

// cookie.ts
export async function sign(data: string, secret: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
  return `${btoa(data)}.${bufferToBase64(sig)}`;
}

export async function verify(token: string, secret: string): Promise<string> {
  const [dataB64, sigB64] = token.split(".");
  const data = atob(dataB64);
  const expected = await sign(data, secret);
  if (!safeEqual(token, expected)) throw new Error("Invalid signature");
  return data;
}

Required flags:

  • HttpOnly: JS can’t read (prevents XSS theft).
  • Secure: HTTPS only.
  • SameSite=Strict (or Lax): CSRF protection.
  • Path: cookie scope.
  • Max-Age: expiration.

Pattern 3: API key + scoped tokens

Public API for third-party developers.

async function authenticateAPIKey(request: Request, env: Env): Promise<APIUser | null> {
  const auth = request.headers.get("Authorization");
  if (!auth?.startsWith("Bearer ")) return null;

  const key = auth.slice(7);
  const hash = await sha256(key);

  const user = await env.DB.prepare(
    "SELECT id, scopes, rate_limit FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL"
  ).bind(hash).first();

  return user as APIUser | null;
}

async function sha256(input: string): Promise<string> {
  const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
}

Store the hash, not plaintext. User losing a key = not recoverable = must reissue (good security property).

Scope check:

if (!user.scopes.includes("posts:write")) {
  return new Response("Insufficient scope", { status: 403 });
}

Pattern 4: OIDC federation

Worker needs to call AWS Bedrock without storing IAM keys. Access generates an OIDC JWT, AWS STS has a trust relationship, returns temp credentials.

Details are in the separate “OIDC federation Cloudflare → AWS” post on this blog. Short summary:

// 1. Worker calls Access to generate the OIDC JWT
const oidcJWT = await env.OIDC_SERVICE.fetch("/oidc/token");

// 2. Call AWS STS AssumeRoleWithWebIdentity
const stsResponse = await fetch("https://sts.amazonaws.com/", {
  method: "POST",
  body: new URLSearchParams({
    Action: "AssumeRoleWithWebIdentity",
    RoleArn: env.AWS_ROLE_ARN,
    WebIdentityToken: oidcJWT,
    DurationSeconds: "3600",
    Version: "2011-06-15",
  }),
});

// 3. Parse XML response → temp credentials
const { AccessKeyId, SecretAccessKey, SessionToken } = parseSTSResponse(stsResponse);

// 4. SigV4-sign the request to Bedrock
await callBedrock({ AccessKeyId, SecretAccessKey, SessionToken });

No IAM keys stored. AWS trusts Cloudflare Access as the IdP.

Input validation with Zod

import { z } from "zod";

const ContactSchema = z.object({
  email: z.string().email().max(255),
  name: z.string().min(1).max(100),
  message: z.string().min(10).max(5000),
});

export async function handleContact(request: Request, env: Env) {
  const body = await request.json();
  
  const result = ContactSchema.safeParse(body);
  if (!result.success) {
    return Response.json({ errors: result.error.errors }, { status: 400 });
  }

  const { email, name, message } = result.data;
  // Safe to use now
}

Zod catches:

  • Missing fields.
  • Wrong types.
  • Length constraints.
  • Email format.

Skipping Zod = untrusted data reaches business logic = dangerous.


Layer 5: Response headers

CSP, HSTS, etc. set via response headers. Astro middleware or Worker:

export default {
  async fetch(request: Request, env: Env) {
    const response = await handleRequest(request, env);

    const headers = new Headers(response.headers);
    
    // HSTS: force HTTPS 1 year
    headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
    
    // Prevent MIME sniff
    headers.set("X-Content-Type-Options", "nosniff");
    
    // Prevent clickjacking
    headers.set("X-Frame-Options", "DENY");
    
    // Referrer
    headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
    
    // Permissions (disable unused features)
    headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
    
    // CSP: minimal
    headers.set("Content-Security-Policy", [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com https://challenges.cloudflare.com",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "font-src 'self' data:",
      "connect-src 'self' https://cloudflareinsights.com",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
    ].join("; "));

    return new Response(response.body, {
      status: response.status,
      headers,
    });
  },
};

This blog uses exactly this CSP pattern. The 19-assertion smoke test (Part 12) checks every one of these headers.

CSP challenge

unsafe-inline for scripts is a compromise. Proper CSP uses nonces:

const nonce = crypto.randomUUID();
headers.set("Content-Security-Policy", `script-src 'self' 'nonce-${nonce}'`);

// Template injects the nonce into every <script>
html = html.replaceAll("<script", `<script nonce="${nonce}"`);

More complex, but prevents inline-script XSS. Production recommendation.

CSP reports

Monitor violations:

Content-Security-Policy: default-src 'self'; report-uri /csp-report

The /csp-report endpoint logs violations. Discover non-compliant 3rd-party scripts.


Layer 6: Storage bindings

Bindings are scoped per-Worker. D1 databases don’t accept internet connections — only Worker bindings. Same for R2 (unless public buckets), KV, Vectorize.

Implications:

  • No need for a firewall in front of D1.
  • No connection strings to leak.
  • Worker compromise = damage scope = database access (still bad, but bounded).

Principle: treat bindings as scoped capabilities. Wrangler secrets for external API keys need the same treatment.


Secret management

wrangler secret

Don’t commit secrets to git. Upload via CLI:

wrangler secret put OPENAI_API_KEY
# enter value

wrangler secret put TURNSTILE_SECRET

Secrets are injected into env at runtime, not visible in wrangler.jsonc.

Rotation

# 1. Generate a new key at the provider
# 2. Update the secret
wrangler secret put OPENAI_API_KEY
# 3. No redeploy needed — secrets hot-reload
# 4. Revoke the old key at the provider

Zero-downtime rotation

Secrets on a critical path (DB password, main API key): dual-credential.

// Code tries both
try {
  return await callWithKey(env.API_KEY_V2);
} catch (e) {
  if (e.status === 401) {
    return await callWithKey(env.API_KEY_V1);  // fallback
  }
  throw e;
}

Rotate:

  1. Set API_KEY_V2 = new.
  2. Revoke API_KEY_V1 at the provider after a stable period.
  3. Remove the API_KEY_V1 secret.

Never log secrets

// WRONG — leaks secrets into logs
console.log("Config:", env);
console.log("Auth:", request.headers);

// RIGHT
console.log("Auth status:", authStatus);
// redact sensitive
const safe = { ...env };
delete safe.SECRET_KEY;
console.log("Config:", safe);

Redact via Tail Worker (Part 17) as an extra layer.


Bot Management: when to enable it

Bot Management is paid (Enterprise). Considerations:

Enable when:

  • E-commerce: bots scrape prices, check inventory 24/7.
  • Login pages: credential stuffing is rampant.
  • Free-tier APIs: free-tier abuse causes real cost.
  • Auth endpoints: brute force bypassing rate limits.
  • Content theft: scrapers cloning the blog.

Skip when:

  • Internal tools: Access already covers it.
  • Low-traffic static blog: WAF + rate limit + Turnstile is enough.
  • Budget-constrained: Super Bot Fight Mode (Pro $20/mo) substitutes, rule-based.

This blog

Static blog, no login, no UGC, no paid API. Bot Management isn’t worth it. Uses:

  • WAF managed rules (free).
  • Super Bot Fight Mode (small — per-route).
  • Turnstile for contact + newsletter.
  • CSP + HSTS for defense-in-depth.

Total security cost: $0 on the free plan, $20/month on Pro.


Anti-patterns

❌ 1. Secrets in code

const API_KEY = "sk-abc123";  // committed!

Rotation isn’t possible. Audit log missing. Use wrangler secret.

❌ 2. Verifying JWT with HS256 + a secret in code

HS256 = symmetric. Clients can forge if the secret leaks. Use RS256 / ES256 (asymmetric) for JWTs if a third party verifies them.

Access uses RS256 with JWKS — proper.

❌ 3. Skipping input validation

const { email } = await request.json();
await env.DB.prepare(`INSERT INTO users VALUES ('${email}')`).run();

SQL injection + no email format check. Use Zod + prepared statements.

❌ 4. Trusting cf-connecting-ip blindly

const ip = request.headers.get("cf-connecting-ip");
// Act on ip

This header is set correctly by Cloudflare. But if the Worker is invoked via a non-Cloudflare path (direct origin), the header can be spoofed. Always trust request.cf?.clientIP on a zone, or validate that cf-ray is present.

❌ 5. CORS wildcards

headers.set("Access-Control-Allow-Origin", "*");
headers.set("Access-Control-Allow-Credentials", "true");  // conflict!

Wildcard + credentials = browser rejects. Allow specific origins:

const origin = request.headers.get("Origin");
const allowed = ["https://cloudsecop.net", "https://khavan.khavan.workers.dev"];
if (allowed.includes(origin)) {
  headers.set("Access-Control-Allow-Origin", origin);
  headers.set("Access-Control-Allow-Credentials", "true");
}

❌ 6. Client-side auth checks

<script>
if (!localStorage.getItem("role") === "admin") window.location = "/";
</script>

An attacker can edit the DOM before the script runs. Auth checks MUST happen server-side (in the Worker).

❌ 7. Logging PII in plaintext

console.log("User email:", email, "password:", password);

Ends up in the log aggregator. GDPR violation. Redact before logging.

❌ 8. Assuming Cloudflare fixes everything

WAF catches 90% of OWASP, not 100%. Zero-days exist. Defense-in-depth — multiple layers, never rely on just one.


Gotchas

① CSP testing is production-only

Strict CSP can break the page. Test with a Content-Security-Policy-Report-Only header first, log violations, iterate, then enforce.

② Turnstile is domain-specific

Turnstile site keys are bound to a domain. Local testing on localhost requires adding the domain in the config. Or use the dummy site key for dev.

The CF_Authorization cookie is set per zone. A subdomain admin.cloudsecop.net needs to share the cookie with the apex → configure Access Application scope correctly.

④ wrangler secret doesn’t list values

wrangler secret list  # names only, no values

Values can’t be read back after setting. Lost = re-set. Store in a password manager (1Password, Bitwarden).

⑤ HSTS preload commitment

The preload directive adds your zone to the Chrome/Firefox preload list. Removing it is hard. Only enable when you’re sure about HTTPS forever.

⑥ Bot fingerprint false positives

Aggressive Bot Management → false positives blocking real users. Monitor dashboard, tune sensitivity. Whitelist known-good bots (Googlebot, Bingbot via verified-bot feature).

⑦ SameSite=Strict breaks redirect flows

SameSite=Strict cookies don’t travel with cross-site redirects (OAuth callbacks). Use Lax for session cookies when there’s an OAuth flow.

⑧ Access bypassed in local dev

wrangler dev has no Access layer. Local testing = no Access. Verify on a staging zone with Access enabled.


Production checklist

  • WAF managed rules enabled (default).
  • Custom WAF rules for admin paths, known abuse patterns.
  • Rate-limit WAF rules for write endpoints (subscribe, login, search).
  • Turnstile on public forms.
  • Cloudflare Access for /admin/* (if applicable).
  • JWT verified via JWKS, no skipping the signature check.
  • Signed cookies HttpOnly + Secure + SameSite for sessions.
  • Zod input validation before any business logic.
  • Secrets via wrangler secret put, never in code.
  • CSP + HSTS + X-Frame-Options + nosniff + Permissions-Policy.
  • Redact sensitive fields in logs.
  • API key hashes stored, not plaintext.
  • Scope check per endpoint (RBAC).
  • Smoke test (Part 12) verifies security headers post-deploy.
  • Incident response playbook: key rotation, session invalidation, user notification.

Wrap-up

Worker security isn’t “write secure code”. It’s stacking controls across 6 layers: WAF, rate limit, Access, Worker auth, response headers, storage bindings. Each layer catches a different class of problem.

Cloudflare provides 4/6 layers at the platform level (WAF, Bot, Access, Turnstile). Skip = rebuild an inferior version + maintain your own security code. Using the platform tier is a real productivity win.

Part 19: Cost model — per-primitive pricing analysis, breakpoints when moving tiers, comparison with real AWS Lambda + API Gateway + DynamoDB + S3 numbers.


References