安全最佳實踐:Workers 的 JWT、限流與 CORS 加固 | Cloudflare 完整教學
這一篇要把上一篇又快又省的應用,再打造得堅不可摧——深入 Cloudflare Workers 的安全最佳實踐。我們會從 Secrets 管理(不寫死、不進 log)出發,逐一實作輸入驗證、JWT 驗證與 API key 授權、CORS 正確設定(不要
*全開)、原生 Rate Limiting binding 限流,並把 WAF 與 Turnstile 放在前面擋機器人、用最小權限 API token。目標:讓你的 Worker 不只跑得快,還守得住。
前言
上一篇《效能最佳化》,我們把架構榨出最後一分延遲與成本,學會「聰明安排等待、盡量少做運算」。這一篇要往另一個維度走:同樣一套又快又省的應用,怎麼讓它守得住。效能決定使用者「等多久」,安全決定你「賠多少」——一次密鑰外洩、一次注入攻擊、一次無限流被打爆,足以抹掉你所有的效能努力。
先給一句話定義:
安全最佳實踐(Security Best Practices)在 Cloudflare Workers 上,核心是「縱深防禦(Defense in Depth)」:把防線一層一層疊起來——最外層用 WAF 與 Turnstile 擋掉機器人與濫用,進到 Worker 後先驗證輸入、再驗證身份(JWT/API key)與授權,對外只開放最小必要的權限(specific CORS origin、最小權限 API token),而所有的密鑰,從頭到尾都不寫死在程式碼、不進版控、不進 log。 沒有哪一層是萬能的,但每多一層,攻擊者要付出的代價就翻倍。
打個比方會更好懂。想像你的 Worker 是一棟金庫大樓。WAF 與 Turnstile 是大門口的保全與人臉閘門,把明顯的搗亂份子(機器人、已知攻擊特徵)擋在門外;進了門,櫃台要驗證你的證件是不是真的(JWT 驗簽,而且要用定時安全的方式核對,不是瞄一眼就放行);證件真了,還要看你有沒有權限進到這個房間(授權,依 role/scope 判斷);而金庫的鑰匙(Secrets)絕不會貼在牆上、寫在交接本裡(不寫死、不進 log),只鎖在保險箱、由需要的人臨時取用。這一篇,就是教你設計 Workers 的「多層金庫」。
讀完這篇你會掌握:
- Secrets 管理——為什麼一定要
wrangler secret put、不能寫死或進 log,以及它和第 013 篇的呼應。 - 驗證與授權——輸入驗證、JWT 定時安全驗簽、API key 比較,以及「驗證身份」與「檢查權限」的差別。
- CORS 與限流——
Access-Control-Allow-Origin為什麼不能*全開,以及用原生 Rate Limiting binding 限流。 - 把防線前移——WAF/Turnstile 擋機器人、最小權限 API token、避免注入——縱深防禦的整體觀。
核心概念
在動手前,先建立一張「安全面向全貌圖」。Workers 的安全問題可以拆成五個面向,每個面向對應不同的防線與武器。
一、五個安全面向:機密、輸入、驗證、授權、限流
| 面向 | 要防什麼 | 常見錯誤 | 主要武器 |
|---|---|---|---|
| 機密(Secrets) | 密鑰外洩 | 寫死在碼、放 vars、進 log | wrangler secret put、.dev.vars |
| 輸入(Input) | 惡意/畸形資料、注入 | 直接信任 request.json() | schema 驗證、參數化查詢 |
| 驗證(Authentication) | 假冒身份 | 土炮解 JWT、=== 比 key | crypto.subtle 驗簽、timingSafeEqual |
| 授權(Authorization) | 越權操作 | 驗了身份就全放行 | 依 role/scope 判斷 |
| 限流(Rate Limiting) | 濫用、暴力破解、DDoS | 完全不限流 | Rate Limiting binding、WAF、Turnstile |
這張表最關鍵的洞見是:「驗證(Authentication)」和「授權(Authorization)」是兩件事,別混為一談。驗證回答「你是誰、這個 token 是不是真的」;授權回答「你這個身份,能不能做這件事」。一個常見漏洞就是驗了 JWT 是有效的、就讓使用者刪別人的資料——身份是真的,但沒檢查權限。正確順序永遠是:先驗證輸入合不合法 → 再驗證身份是不是真的 → 再檢查這個身份有沒有權限 → 最後才執行業務邏輯。
另一個要建立的心態是縱深防禦:不要指望單一防線。就算你 JWT 驗得再嚴,若密鑰寫死在程式碼裡被人翻 git history 挖走,整條防線瞬間崩塌;就算密鑰藏得再好,若 CORS 全開讓惡意網站誘導使用者的瀏覽器發請求,一樣被繞過。安全不是「找到那個最強的鎖」,而是「疊很多層,讓攻擊者每一層都要重新突破」。
二、把防線前移:WAF 與 Turnstile
很多人以為安全都在 Worker 程式碼裡,其實最有效率的防線是在流量進到 Worker 之前。Cloudflare 在 Worker 前面就有兩道可用的閘門:
- WAF(Web Application Firewall):在邊緣依規則(已知攻擊特徵、地理、IP 信譽、速率)攔截惡意請求。搭配 WAF Rate Limiting 規則,可以在流量還沒消耗你任何 Worker CPU 時,就把暴力破解、爬蟲濫用擋下。
- Turnstile:Cloudflare 的 CAPTCHA 替代方案,對真人幾乎無感,卻能有效擋掉機器人。用來保護登入、註冊、表單提交、留言等「機器人最愛濫用」的端點。前端嵌入 widget 取得 token,伺服器端再向
https://challenges.cloudflare.com/turnstile/v0/siteverify驗證這個 token(每個 token 只能驗一次、有效期 300 秒)。
把這兩道放前面的好處是:能在 Worker 之外擋掉的濫用流量,連 Worker 都不會被喚醒,既省成本又縮小攻擊面。
⚠️ 一個容易忽略的陷阱:Worker 向「同一個 Cloudflare zone」的 URL 發子請求時,會繞過該 zone 的 WAF 規則。也就是說你以為 WAF 保護著後端,結果內部 Worker 呼叫繞了過去。修正方式是改用 Service Bindings(見第 041 篇)取代 HTTP 子請求,或透過 Zero Trust Tunnel 路由內部流量。
三、幾個關鍵術語
- Secret vs Var:
vars是明文設定(會進 Dashboard、可放非敏感值);Secret 是加密儲存的敏感值,用wrangler secret put設定,Dashboard 也看不到明文。密鑰一律走 Secret。 - JWT(JSON Web Token): 一種帶簽章的權杖,由 header、payload、signature 三段組成。驗證的重點不是「解得開」而是「驗得了簽」——確認它由持有密鑰的一方簽發、未被竄改、未過期。
- Timing-safe 比較: 用
crypto.subtle.timingSafeEqual()比較密鑰,不論哪裡不一致耗時都固定,防止 timing 側信道攻擊。 - 最小權限(Least Privilege): API token 只授予「完成任務所需的最小權限與範圍」,一旦外洩,損害面也最小。
實作範例
理論看完,把幾個核心的安全機制實際寫出來。以下程式碼都基於 Workers 內建的 Web Crypto API,不需額外套件,可直接作為專案起點。
1. Secrets:不寫死、不進 vars、不進 log
第一原則,也最容易犯:密鑰絕不寫死。這一段和第 013 篇《環境管理與 Secrets》一脈相承——vars 放非敏感設定,真正的密鑰走 wrangler secret put:
# ✅ 用 wrangler secret put 設定生產密鑰(輸入時不回顯、加密儲存)
npx wrangler secret put OPENAI_API_KEY
npx wrangler secret put JWT_SIGNING_SECRET
npx wrangler secret list # 只列出名稱,不顯示值
# 本機開發:用 .dev.vars,並務必加入 .gitignore
echo "JWT_SIGNING_SECRET=dev-secret-xxx" >> .dev.vars
echo ".dev.vars" >> .gitignore
程式裡一律透過 env 存取,而且要小心別把密鑰印進 log——因為 log 會被 observability / Logpush 匯出到外部系統:
// src/index.ts
export interface Env {
JWT_SIGNING_SECRET: string; // 由 wrangler secret put 設定,wrangler types 產生
API_KEY: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// ❌ 絕對不要:密鑰、完整 Authorization 標頭、密碼進 log
// console.log('auth header:', request.headers.get('Authorization'));
// ✅ 要記 log 就記「安全的中繼資料」,不含機密本身
console.log(JSON.stringify({
msg: 'request received',
path: new URL(request.url).pathname,
hasAuth: request.headers.has('Authorization'), // 只記「有沒有」,不記值
}));
// ✅ 密鑰透過 env 存取,不硬編碼
return fetch('https://api.openai.com/v1/models', {
headers: { Authorization: `Bearer ${env.OPENAI_API_KEY}` },
});
},
} satisfies ExportedHandler<Env>;
2. 輸入驗證:別直接信任 request.json()
任何來自使用者的輸入都是「不可信」的。直接把 await request.json() 的結果拿去用,是最常見的漏洞入口。先驗證結構與型別,再進業務邏輯:
// 輕量的手寫 schema 驗證(生產專案也可用 zod / valibot)
interface SignupInput {
email: string;
age: number;
}
function parseSignup(raw: unknown): SignupInput | null {
if (typeof raw !== 'object' || raw === null) return null;
const r = raw as Record<string, unknown>;
// 逐欄檢查型別與合理範圍
if (typeof r.email !== 'string') return null;
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(r.email)) return null; // 基本 email 格式
if (r.email.length > 254) return null; // 限制長度,防 DoS
if (typeof r.age !== 'number' || !Number.isInteger(r.age)) return null;
if (r.age < 0 || r.age > 150) return null; // 合理範圍
return { email: r.email, age: r.age };
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
let raw: unknown;
try {
raw = await request.json();
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400 });
}
const input = parseSignup(raw);
if (!input) {
return Response.json({ error: 'Validation failed' }, { status: 422 });
}
// input 到這裡才是可信、型別安全的
return Response.json({ ok: true, email: input.email });
},
} satisfies ExportedHandler<Env>;
避免注入的關鍵原則:操作資料庫時永遠用參數化查詢,絕不用字串拼接把使用者輸入直接塞進 SQL:
// ❌ 反模式:字串拼接 → SQL 注入
// const q = `SELECT * FROM users WHERE email = '${input.email}'`;
// ✅ 正確:D1 參數化查詢,輸入被當「資料」而非「程式碼」
const user = await env.DB
.prepare('SELECT id, email FROM users WHERE email = ?')
.bind(input.email)
.first();
3. JWT 驗證中介 + API key 定時安全比較
驗證身份的兩種常見方式:JWT(使用者登入後拿的權杖)與 API key(機器對機器)。兩者都要用 crypto.subtle,不能土炮。先看 JWT 驗簽——重點是「驗簽 + 檢查過期」,不是解得開就信:
// jwt.ts — 用 Web Crypto 驗證 HMAC-SHA256 簽章的 JWT
function base64UrlToBytes(s: string): Uint8Array {
const b64 = s.replace(/-/g, '+').replace(/_/g, '/').padEnd(s.length + (4 - s.length % 4) % 4, '=');
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
export async function verifyJWT(
token: string,
secret: string,
): Promise<{ sub: string; role: string; exp: number } | null> {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
// 1. 用密鑰重算簽章,並「驗證」token 帶來的簽章
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const valid = await crypto.subtle.verify(
'HMAC',
key,
base64UrlToBytes(signatureB64),
new TextEncoder().encode(`${headerB64}.${payloadB64}`),
);
if (!valid) return null; // 簽章不符 → token 被竄改或非我方簽發
// 2. 解 payload,檢查是否過期(exp 為 Unix 秒)
const payload = JSON.parse(new TextDecoder().decode(base64UrlToBytes(payloadB64)));
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
return null; // 已過期
}
return payload;
}
把它包成中介層,並示範驗證 vs 授權的差別——驗證身份後,還要依 role 檢查權限:
// src/index.ts — 驗證(是誰) → 授權(能不能) → 業務邏輯
import { verifyJWT } from './jwt';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Authentication:驗證身份
const auth = request.headers.get('Authorization') ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
const claims = await verifyJWT(token, env.JWT_SIGNING_SECRET);
if (!claims) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// Authorization:身份是真的,但有沒有權限做這件事?
const url = new URL(request.url);
if (url.pathname.startsWith('/admin') && claims.role !== 'admin') {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
return Response.json({ ok: true, user: claims.sub, role: claims.role });
},
} satisfies ExportedHandler<Env>;
若是 API key(機器對機器)驗證,重點是用 定時安全比較 防 timing 攻擊,絕不用 ===:
// ✅ 用 crypto.subtle.timingSafeEqual 比較 API key,防 timing 側信道攻擊
function safeEqual(a: string, b: string): boolean {
const ea = new TextEncoder().encode(a);
const eb = new TextEncoder().encode(b);
// 長度不同時直接回 false(長度本身不算機密)
if (ea.byteLength !== eb.byteLength) return false;
return crypto.subtle.timingSafeEqual(ea, eb);
}
function checkApiKey(request: Request, env: Env): boolean {
const provided = request.headers.get('X-API-Key') ?? '';
return safeEqual(provided, env.API_KEY); // ❌ 不要寫成 provided === env.API_KEY
}
4. CORS 正確設定:不要 * 全開
CORS 決定「哪些網站的瀏覽器能呼叫你的 API」。只要 API 牽涉使用者身份(帶 Cookie / Authorization),Access-Control-Allow-Origin: * 就是危險的——正確做法是維護白名單,只回傳請求來源本身(且需在名單內):
// cors.ts — 白名單式 CORS,不用 * 全開
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
export function corsHeaders(request: Request): Record<string, string> {
const origin = request.headers.get('Origin') ?? '';
// 只在來源於白名單時,才回傳「那一個」具體來源(不是 *)
if (!ALLOWED_ORIGINS.has(origin)) return {};
return {
'Access-Control-Allow-Origin': origin, // 回傳具體來源,而非 *
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Allow-Credentials': 'true', // 帶認證時必須,且不能與 * 併用
'Vary': 'Origin', // 讓快取正確區分不同來源
'Access-Control-Max-Age': '86400',
};
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// 預檢請求(preflight)先回 CORS 標頭
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders(request) });
}
const res = Response.json({ data: 'ok' });
for (const [k, v] of Object.entries(corsHeaders(request))) res.headers.set(k, v);
return res;
},
} satisfies ExportedHandler<Env>;
5. Rate Limiting binding:原生限流
Cloudflare 提供原生的 Rate Limiting binding,比自己用 KV 土炮滑動視窗更準、延遲更低。先在 wrangler.jsonc 綁定:
// wrangler.jsonc — 綁定原生 Rate Limiting
{
"name": "secure-worker",
"main": "src/index.ts",
"compatibility_date": "2026-09-01",
"compatibility_flags": ["nodejs_compat"],
"ratelimits": [
{
"name": "API_LIMITER",
"namespace_id": "1001",
"simple": { "limit": 100, "period": 60 } // 每 60 秒最多 100 次
}
],
"observability": { "enabled": true, "head_sampling_rate": 1 }
}
程式裡用 env.API_LIMITER.limit({ key }) 判斷是否超限,超限回 429 並帶 Retry-After:
export interface Env {
API_LIMITER: RateLimit; // 原生 Rate Limiting binding
JWT_SIGNING_SECRET: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// 以來源 IP 當限流鍵(也可用 user id、API key 等)
const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown';
const { success } = await env.API_LIMITER.limit({ key: ip });
if (!success) {
return new Response('Too Many Requests', {
status: 429,
headers: { 'Retry-After': '60' },
});
}
return Response.json({ ok: true });
},
} satisfies ExportedHandler<Env>;
常見錯誤與最佳實踐
安全的另一半,是認得那些「悄悄開後門」的坑。以下五個是 Workers 上最常見的安全陷阱。
陷阱一:密鑰寫死、放 vars、或印進 log。 硬編碼在原始碼或 wrangler.jsonc 的 vars 會被提交進 git history、Dashboard 明文顯示,等於公開金鑰;把密鑰或完整 Authorization 標頭 console.log 出來,則會被 observability / Logpush 匯出到外部系統。正確做法: 一律 wrangler secret put,本機用 .dev.vars(加進 .gitignore),log 只記「有沒有 auth」這類中繼資料、絕不記值。呼應第 013 篇——vars 給非敏感設定,Secret 給密鑰。
陷阱二:CORS 全開 Access-Control-Allow-Origin: *。 對帶身份的 API 全開,等於讓任何惡意網站都能誘導使用者瀏覽器打你的 API,而且 * 不能與 Access-Control-Allow-Credentials: true 併用。正確做法: 維護白名單,只回傳「請求來源本身且在名單內」的具體 origin,並加上 Vary: Origin。
陷阱三:完全不限流。 沒有限流,登入端點會被暴力破解、公開 API 會被爬爆、成本失控。正確做法: 用原生 Rate Limiting binding 做應用層限流,前面再疊 WAF Rate Limiting 規則,超限回 429 帶 Retry-After。
陷阱四:未驗證輸入 + 字串拼接查詢。 直接信任 request.json()、把使用者輸入拼進 SQL,是注入攻擊的門戶。正確做法: 先做 schema/型別/範圍驗證(不合法回 400/422),資料庫操作一律用參數化查詢(.bind()),讓輸入被當「資料」而非「程式碼」。
陷阱五:土炮驗 JWT、用 === 比密鑰。 只解 base64 就信任 payload,等於誰都能偽造 token;用 === 比 API key,會洩漏 timing 側信道。正確做法: 用 crypto.subtle.verify 驗簽並檢查 exp,用 crypto.subtle.timingSafeEqual 做定時安全比較,並記得「驗證身份」之後還要「檢查授權」。
最佳實踐小結:
- 密鑰三不——不寫死、不進
vars、不進 log;wrangler secret put+.dev.vars+.gitignore。 - 先驗輸入、再驗身份、再檢授權——三道順序不能省;資料庫一律參數化查詢。
- 驗簽用
crypto.subtle、比 key 用timingSafeEqual——別土炮、別===。 - CORS 白名單、限流用原生 binding——具體 origin 不用
*;超限回429。 - 防線前移 + 最小權限——WAF/Turnstile 擋機器人在 Worker 之前;API token 只給最小必要權限。
小結
上一篇《效能最佳化》,我們把應用調得又快又省;這一篇《安全最佳實踐》,我們在同一套應用上,把防線一層層疊起來,讓它守得住:
- 機密與輸入——Secrets 不寫死、不進
vars、不進 log(呼應第 013 篇);輸入先驗證再使用、資料庫參數化避免注入。 - 驗證與授權——JWT 用
crypto.subtle驗簽並查過期、API key 用timingSafeEqual定時安全比較;「驗證身份」與「檢查授權」分兩步。 - CORS 與限流——
Access-Control-Allow-Origin用白名單具體來源、不用*;用原生 Rate Limiting binding 限流,前面再疊 WAF/Turnstile 擋機器人。 - 縱深防禦——最小權限 API token、防線前移,每多一層,攻擊者代價就翻倍。
一句話收束整篇:Workers 的安全,是「縱深防禦」的藝術——不寄望單一防線,而是把機密、輸入、驗證、授權、限流一層層疊起來,再把最外層的 WAF/Turnstile 前移。記住「密鑰三不、先驗後用、驗簽比 key 用 crypto、CORS 白名單、原生限流、最小權限」,你的 Worker 就能既快又穩、堅不可摧。
掌握了「怎麼跑得安全」,下一步就是把前面所有的零件——路由、儲存、驗證、限流、效能、安全——組裝成一個真正的產品。下一篇《全端實戰》,我們會從零打造一個完整的全端應用,把這 48 篇學到的觀念全部串起來,做出一個可上線的成品。我們下一篇見。
想查閱 Workers 安全與 Rate Limiting 的官方最新指南,可以參考 Cloudflare Rate Limiting 官方文件(各項功能名稱、限制與支援矩陣以當下官方文件為準)。