Security cho Worker: secrets, CSP, Bot Management, Turnstile

Defense-in-depth cho Cloudflare Worker: WAF + Bot Management, Turnstile, Access JWT, secret management, CSP/HSTS, 4 pattern auth, validation Zod, và anti-pattern cần tránh.

· 8 phút đọc · Read in English
Defense-in-depth 6 tầng cho Worker: WAF + Bot Management ở edge, Turnstile cho form, Access JWT cho admin, wrangler secrets, CSP/HSTS header, validate input Zod và 4 pattern auth (Access, signed cookie, API key, OIDC)

TL;DR

Worker không phải server riêng. Không có firewall cá nhân, không systemd, không selinux. Security đi theo mô hình defense-in-depth 6 tầng từ edge vào trong:

  1. WAF + Bot Management — edge trước Worker. OWASP, rule-based, bot score.
  2. Rate limit + Turnstile — quota request, captcha invisible cho form.
  3. Cloudflare Access — zero-trust JWT cho admin route.
  4. Worker auth + validate — verify JWT/cookie, Zod schema input.
  5. Response headers — CSP, HSTS, X-Frame-Options, Referrer-Policy.
  6. Storage bindings — D1/KV/R2 scoped per Worker, không public internet.

Luận điểm chính:

Worker security không phải “viết handler bảo mật”. Cloudflare cung cấp nhiều control ở tầng platform (WAF, Bot, Access, Turnstile) rẻ hơn và an toàn hơn tự code. Dev bỏ qua layer này thường xây dựng lại phần kém hơn trong Worker. Một app production cần cả platform control + app-level check.

Bài này đi qua: 6 tầng với config thực tế, 4 pattern auth (Access, signed cookie, API key, OIDC), secret management chuẩn, CSP header tối thiểu, Bot Management rule, và 8 anti-pattern.


Dành cho ai

  • Dev mới deploy Worker, chưa nghĩ nhiều về security.
  • Team có admin dashboard, cần protect tránh bot.
  • Ai đang quản lý secret qua .env muốn chuyển sang wrangler secret.

Nên đọc trước: Part 3 (bindings), Part 17 (observability).

Sau bài này bạn sẽ:

  • Setup Access cho admin route trong < 15 phút.
  • Implement signed cookie session cho app user.
  • Deploy CSP header tối thiểu không break existing page.
  • Biết khi nào Bot Management đáng bật, khi nào Turnstile đủ.

Bài này không nói về gì

  • OWASP top 10 chi tiết: assume bạn biết XSS, SQL injection, CSRF. Bài focus vào Worker-specific control.
  • Enterprise WAF custom rule language deep: cần bài riêng. Focus preset + common pattern.
  • Penetration testing / offensive: bài này là defensive.
  • Compliance (SOC2, ISO 27001): cấp organizational, không cover.

6 tầng defense-in-depth

6 tầng bảo vệ Worker từ ngoài vào trong: Cloudflare WAF/Bot Management, Rate limit + Turnstile, Access JWT cho admin, Worker secrets validation input với Zod, CSP/HSTS security headers, Storage binding D1/KV/R2 scoped.

Không cần implement cả 6. Blog tĩnh chỉ cần tầng 5 (header). App UGC full cần 1-6.


Tầng 1: WAF + Bot Management

WAF managed rule

Cloudflare WAF có preset rule (OWASP Core Rule Set) chặn SQL injection, XSS, LFI, RFI pattern.

Bật mặc định trên tất cả zone (Free + Paid).

Dashboard: SecurityWAFManaged Rules. Có thể tune sensitivity (low/medium/high).

WAF custom rule

Cho trường hợp specific:

(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 mạnh. Kết hợp field về IP, UA, path, header, body.

Bot Management

Cloudflare compute “bot score” 0-99 cho mỗi request dựa trên:

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

Rule theo score:

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

Enterprise feature, không free. Dùng khi:

  • E-commerce: anti-scraping, anti-credential-stuffing.
  • Dev tool public: anti-abuse free tier.
  • Content site: chặn content theft bot.

Alternative miễn phí: Super Bot Fight Mode (Pro+), rule-based không có score.


Tầng 2: Rate limit + Turnstile

Rate limit native (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 rule. Business+: unlimited.

Miễn phí alternative: WAF custom rule với rate() function (limited).

Rate limit custom với Durable Object

Cần logic phức tạp (sliding window, per-user, per-endpoint) → DO rate limiter (Part 15).

Turnstile

Captcha invisible cho form, replacement 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 inject hidden field cf-turnstile-response với 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 miễn phí, không limit. Dùng cho:

  • Contact form.
  • Newsletter signup.
  • Login page (trước password).
  • Rate-limited public API.

Tầng 3: Cloudflare Access

Zero-trust auth cho admin route. SSO với Google/Okta/GitHub, JWT injected vào 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 truy cập /admin/* → redirect Access login → SSO → JWT trong cookie CF_Authorization.

Verify trong 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 fetch JWKS từ Cloudflare, verify 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, ... }
}

Audience (AUD) tag identify app. Get từ Access application config.

Access JWT flow

4 pattern auth cho Worker: Access JWT (SSO zero-trust cho admin), Signed cookie HMAC (session app user thường), API key + scoped token (machine-to-machine), OIDC federation (Worker tới AWS STS qua Access).

Service Auth token

Machine-to-machine call Access-protected endpoint: dùng Service Token.

Dashboard → Access → Service Auth → Create Service Token. Lấy 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 verify, forward request. Worker receive same JWT pattern.


Tầng 4: Worker auth + validate

Pattern 1: Cloudflare Access (đã cover)

User-facing app (blog commenter, shop buyer) không fit Access. Session cookie HMAC-signed.

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

async function login(email: string, password: string, env: Env) {
  // verify password từ 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 không đọc được (chống XSS steal).
  • Secure: chỉ HTTPS.
  • SameSite=Strict (or Lax): chống CSRF.
  • Path: scope cookie.
  • Max-Age: expire time.

Pattern 3: API key + scoped token

Public API cho developer.

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 hash, không store plaintext. User lose key không recoverable = phải reissue (good security property).

Scope check:

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

Pattern 4: OIDC federation

Worker cần gọi AWS Bedrock không lưu IAM key. Access gen OIDC JWT, AWS STS trust relationship, trả temp credential.

Chi tiết ở post riêng “OIDC federation Cloudflare → AWS” trong blog này. Short summary:

// 1. Worker call Access để gen 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 request tới Bedrock
await callBedrock({ AccessKeyId, SecretAccessKey, SessionToken });

Không IAM key stored. AWS trust Cloudflare Access như IdP.

Input validation với 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 catch:

  • Missing field.
  • Wrong type.
  • Length constraint.
  • Email format.

Không parse qua Zod = untrusted data reach business logic = dangerous.


Tầng 5: Response headers

CSP, HSTS, v.v. set qua header response. Astro middleware hoặc 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: tối thiểu
    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,
    });
  },
};

Blog này dùng CSP chính xác pattern trên. 19 assertion smoke test (Part 12) include check mọi header này.

CSP challenge

unsafe-inline cho script là compromise. Proper CSP dùng nonce:

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

// Template inject nonce vào mọi <script>
html = html.replaceAll("<script", `<script nonce="${nonce}"`);

Phức tạp hơn, nhưng prevent được XSS inline script. Production recommendation.

CSP report

Monitor violation:

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

Endpoint /csp-report log violation. Discover 3rd-party script không compliant.


Tầng 6: Storage bindings

Binding scope per-Worker. D1 database không accept connection từ internet — chỉ Worker binding. Same R2 (trừ public bucket), KV, Vectorize.

Implication:

  • Không cần firewall cho D1.
  • Không leak connection string.
  • Worker compromise = scope damage = database access (still bad, but bounded).

Principle: treat binding as scoped capability. Wrangler secret cho API key external cần tương tự.


Secret management

wrangler secret

Không commit secret vào git. Upload qua CLI:

wrangler secret put OPENAI_API_KEY
# enter value

wrangler secret put TURNSTILE_SECRET

Secrets inject vào env runtime, không visible trong wrangler.jsonc.

Rotation

# 1. Generate new key ở provider
# 2. Update secret
wrangler secret put OPENAI_API_KEY
# 3. Không cần redeploy — secret hot-reload
# 4. Revoke old key ở provider

Zero-downtime rotation

Secret critical path (DB password, API key chính): dual-credential.

// Code try 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 ở provider sau stable period.
  3. Remove API_KEY_V1 secret.

Never log secret

// SAI — secret leak vào log
console.log("Config:", env);
console.log("Auth:", request.headers);

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

Redact qua Tail Worker (Part 17) extra layer.


Bot Management: khi nào đáng bật

Bot Management là paid (Enterprise). Cân nhắc:

Bật khi:

  • E-commerce: bot scrape price, check inventory 24/7.
  • Login page: credential stuffing rampant.
  • Free tier API: abuse free tier gây cost real.
  • Auth endpoint: brute force tránh rate limit.
  • Content theft: scraper clone blog.

Không cần:

  • Internal tool: Access đã cover.
  • Static blog low-traffic: WAF + rate limit + Turnstile đủ.
  • Budget constraint: Super Bot Fight Mode (Pro $20/mo) thay thế, rule-based.

Blog này

Static blog, no login, no UGC, no paid API. Bot Management không đáng. Dùng:

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

Total security cost: $0 cho free plan, $20/tháng Pro.


Anti-pattern

❌ 1. Store secret trong code

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

Rotation không thể. Audit log không có. Dùng wrangler secret.

❌ 2. Verify JWT bằng HS256 với secret trong code

HS256 = symmetric. Client có thể forge nếu secret leak. Dùng RS256 / ES256 (asymmetric) cho JWT nếu có 3rd party verify.

Access dùng RS256 with JWKS — proper.

❌ 3. Skip input validation

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

SQL injection + không kiểm email format. Dùng Zod + prepared statement.

❌ 4. Trust cf-connecting-ip blindly

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

Header này Cloudflare set correctly. Nhưng nếu Worker invoked từ non-Cloudflare path (direct origin), header có thể spoof. Always trust request.cf?.clientIP trên zone, hoặc validate cf-ray present.

❌ 5. CORS wildcard

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

Wildcard + credentials = browser reject. Allow specific origin:

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 check

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

Attacker edit DOM trước script run. Auth check MUST ở server (Worker).

❌ 7. Log PII plaintext

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

Dump vào log aggregator. GDPR violation. Redact trước log.

❌ 8. Assume Cloudflare fix everything

WAF catch 90% OWASP, không 100%. Zero-day tồn tại. Defense-in-depth — layer nhiều tầng, không rely 1 tầng duy nhất.


Gotcha

① CSP test production-only

CSP strict có thể break page. Test với Content-Security-Policy-Report-Only header trước, log violation, iterate, mới bật enforce.

② Turnstile domain specific

Turnstile site key bound domain. Test local localhost cần add domain trong config. Hoặc dùng dummy site key cho dev.

CF_Authorization cookie set cho zone. Subdomain admin.cloudsecop.net cần share cookie với apex → config Access Application scope đúng.

④ wrangler secret không list value

wrangler secret list  # chỉ tên, không value

Giá trị set xong không read lại. Lost = re-set. Store ở password manager (1Password, Bitwarden).

⑤ HSTS preload commitment

preload directive add zone vào Chrome/Firefox preload list. Remove khó. Chỉ enable khi chắc HTTPS forever.

⑥ Bot fingerprint false positive

Bot Management aggressive → false positive block user thật. Monitor dashboard, tune sensitivity. Whitelist known-good bot (Googlebot, Bingbot qua verified bot feature).

⑦ SameSite=Strict break redirect flow

SameSite=Strict cookie không gửi theo cross-site redirect (OAuth callback). Dùng Lax cho session cookie nếu có OAuth flow.

⑧ Access bypass local dev

wrangler dev không có Access layer. Test local = không Access. Verify trên staging zone với Access enabled.


Production checklist

  • WAF managed rule enabled (default).
  • Custom WAF rule cho admin path, known abuse pattern.
  • Rate limit WAF rule cho write endpoint (subscribe, login, search).
  • Turnstile cho public form.
  • Cloudflare Access cho /admin/* (nếu có).
  • JWT verify qua JWKS, không skip signature check.
  • Signed cookie HttpOnly + Secure + SameSite cho session.
  • Input validation Zod trước mọi business logic.
  • Secret qua wrangler secret put, không trong code.
  • CSP + HSTS + X-Frame-Options + nosniff + Permissions-Policy.
  • Log redact sensitive field.
  • API key hash stored, không plaintext.
  • Scope check per endpoint (RBAC).
  • Smoke test (Part 12) verify security header post-deploy.
  • Incident response playbook: key rotation, session invalidate, user notify.

Kết

Worker security không phải “viết code bảo mật”. Nó là stacking control ở 6 tầng: WAF, rate limit, Access, Worker auth, response header, storage binding. Mỗi tầng catch class khác nhau.

Cloudflare cung cấp 4/6 tầng ở platform level (WAF, Bot, Access, Turnstile). Skip = rebuild với quality thấp hơn + maintain code security. Dùng platform tier là productivity win thực.

Part 19: Cost model — phân tích pricing từng primitive, breakpoint khi chuyển tier, so sánh với AWS Lambda + API Gateway + DynamoDB + S3 real numbers.


Tham khảo