這一課完成後:你會使用 environment variables 管理環境設定與 secret、建立可重用的 validation、讓 4xx / 5xx error 不洩漏內部細節,並能說明 same-origin、CORS、preflight、credentials 與 CSRF 彼此到底是什麼關係。
先把「邊界」想成不可信資料進入系統的地方
到目前為止,你的 Web App 已經有很多入口:
HTTP Request → method / path / headers / body
Cookie → session identity
Database → persisted data
Browser Origin → 哪個網站正在呼叫你的 API
真正容易出事的地方,通常不是某一行語法,而是「你把哪個輸入當成可信」、「什麼資訊可以被送出去」、「誰能跨過這個邊界」。
Secret 和一般 config 不完全一樣
先分兩類:
Secret → 第三方 API key、database password、private signing key
兩者都適合從 environment 注入,但 secret 還多一個要求:不能因為方便就進 Git、前端 bundle、log 或錯誤訊息。
上一課的 opaque session token 是每個登入 session 的秘密值;它由程式隨機產生,不需要你手動寫一個固定 session secret。未來接第三方服務時,才會更常出現長期 API key。
不要把環境差異硬寫在 server.js
這種寫法會讓 source code 同時綁死設定與程式:
const port = 3000;
const appOrigin = "http://localhost:5500";
const apiKey = "super-secret-key";
改成從 process.env 讀:
const port = Number(process.env.PORT ?? 3000);
const appOrigin = process.env.APP_ORIGIN ?? "http://localhost:5500";
const apiKey = process.env.THIRD_PARTY_API_KEY;
Node.js 的 environment variables 都會以字串形式出現在 process.env,所以像 port、boolean 這類型別要自己轉換。
本機可以用 .env,但 .env 本身不是保險箱
建立:
.env
例如:
PORT=3000
APP_ORIGIN=http://localhost:5500
THIRD_PARTY_API_KEY=replace-me
目前 Node.js 可以直接:
node --env-file=.env server.js
它會把檔案中的值載入 process.env。
.env 只是檔案。如果你把它 commit 到公開 repository、傳到群組、放進 log,它一樣會洩漏。它的價值是把環境設定移出 source code,不是提供加密。
.gitignore 要先擋住真正的 secret file
.env
.env.*
!.env.example
data.sqlite
data.sqlite-shm
data.sqlite-wal
另外建立可以 commit 的:
.env.example
內容只留欄位與假值:
PORT=3000
APP_ORIGIN=http://localhost:5500
THIRD_PARTY_API_KEY=your-key-here
.env.example → 欄位說明 → 可以進 Git
如果 secret 已經 commit,刪檔不等於沒洩漏
假設 API key 已經出現在 Git history,之後新增 .gitignore 並刪掉檔案,不代表舊 commit 裡的值消失。
處理原則是:
不要只做「把畫面上的字刪掉」就繼續使用同一把 key。
必要設定缺少時,server 應該 fail fast
如果某個 production 功能沒有 secret 就不能安全運作,不要默默用空字串繼續。
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing environment variable: ${name}`);
}
return value;
}
const appOrigin = requireEnv("APP_ORIGIN");
這種錯誤應該在 server 啟動時就被看見,而不是等第一個使用者撞到才發現。
Validation:不要讓 route 自己各寫一套猜測
上一課已經做過:
if (
typeof body.title !== "string" ||
body.title.trim() === ""
) {
// 400
}
當 route 變多,這種判斷如果散落在每個地方會很快失控。
先集中成函式:
function validateTaskInput(body) {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return { ok: false, error: "Body must be an object" };
}
if (typeof body.title !== "string") {
return { ok: false, error: "title must be a string" };
}
const title = body.title.trim();
if (title.length === 0 || title.length > 200) {
return {
ok: false,
error: "title must contain 1 to 200 characters"
};
}
return {
ok: true,
value: { title }
};
}
Route 只需要:
const validation = validateTaskInput(body);
if (!validation.ok) {
sendError(response, 400, "INVALID_INPUT", validation.error);
return;
}
const { title } = validation.value;
Validation、Normalization、Authorization 不要混成同一件事
Normalization → trim / lowercase 等一致化
Authentication → 你是誰
Authorization → 你能不能做這件事
例如註冊 email:
const email = body.email.trim().toLowerCase();
這是 normalization。至於「這個 user 能不能刪 task 38」,則是 authorization。
只接受你打算使用的欄位
Client 可能送:
{
"title": "My task",
"user_id": 1,
"is_admin": true
}
不要把整個 body 直接 spread 進 database operation:
// 不要這樣思考
const task = { ...body };
應該由 backend 明確挑出允許欄位:
const { title } = validation.value;
user_id 仍然必須由 authenticated session 決定,不由 client 指定。
Database constraint 仍然要保留
Request validation 很重要,但 database schema 也應該守住基本 invariants:
title TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
Database constraint → 最後一道資料完整性保護
兩層不是互相取代。
Error response 應該有穩定形狀
與其每個 route 都回不同格式:
{ "error": "bad" }
{ "message": "wrong" }
{ "reason": "failed" }
可以統一:
function sendError(response, statusCode, code, message) {
sendJson(response, statusCode, {
error: {
code,
message
}
});
}
例如:
sendError(
response,
400,
"INVALID_INPUT",
"title must contain 1 to 200 characters"
);
Frontend 就能穩定地讀 error.code / error.message。
500 不要把 stack trace、SQL、secret 回給使用者
內部可能真的發生:
SQLITE_CONSTRAINT...
/path/to/server.js:123...
THIRD_PARTY_API_KEY=...
這些可以出現在受控的 server log,但不要原封不動送進 HTTP response。
try {
// backend work
} catch (error) {
console.error(error);
sendError(
response,
500,
"INTERNAL_ERROR",
"Something went wrong"
);
}
Public error response → 給 client 足夠但有限的資訊
Same-origin:Browser 會看 scheme、host、port
這兩個不是同一個 origin:
http://localhost:5500
http://localhost:3000
雖然 host 都是 localhost,但 port 不同。
當 frontend JavaScript 嘗試從 5500 fetch 3000,browser 的 same-origin policy 會介入。這時如果你真的需要跨來源讀取 response,就要由 backend 明確允許 CORS。
CORS 是 Browser 的跨來源讀取規則,不是登入系統
CORS 全名是 Cross-Origin Resource Sharing。
最重要的心理模型:
↓
Backend 用 CORS response headers 表達允許範圍
↓
Browser 決定 frontend JavaScript 能不能讀 response
CORS 不是 authorization。非 browser client,例如 curl 或另一台 server,不會因為你沒給 CORS header 就自動被擋住。
最簡單的開發情境:只允許一個 frontend origin
從 environment 取得:
const appOrigin = requireEnv("APP_ORIGIN");
建立 helper:
function applyCors(request, response) {
const origin = request.headers.origin;
if (!origin) {
return true;
}
if (origin !== appOrigin) {
return false;
}
response.setHeader(
"Access-Control-Allow-Origin",
origin
);
response.setHeader(
"Access-Control-Allow-Credentials",
"true"
);
response.setHeader(
"Access-Control-Allow-Headers",
"Content-Type"
);
response.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PATCH, DELETE, OPTIONS"
);
response.setHeader("Vary", "Origin");
return true;
}
如果你使用 cookie session,而且 frontend / API 是 cross-origin,不能把 credentialed response 的 Access-Control-Allow-Origin 寫成 *;必須是明確 origin。
Preflight:有些 request 會先來一個 OPTIONS
Browser 在真正的 cross-origin request 前,某些情況會先問 server:
允許 → Browser 再送真正 request
在 route matching 前處理:
if (!applyCors(request, response)) {
sendError(response, 403, "ORIGIN_NOT_ALLOWED", "Origin not allowed");
return;
}
if (request.method === "OPTIONS") {
response.writeHead(204);
response.end();
return;
}
如果忘記 OPTIONS,你可能會看到「POST route 明明存在,browser 卻根本沒有打到它」。
Cross-origin cookie request 還要在 frontend 明確 include credentials
const response = await fetch(
"http://localhost:3000/api/me",
{
credentials: "include"
}
);
對 cross-origin fetch,credentials: "include" 告訴 browser 這個 request 要包含符合條件的 credentials,例如 cookies。
Backend 同時要回:
Access-Control-Allow-Origin: http://localhost:5500
Access-Control-Allow-Credentials: true
只做其中一邊不夠。
最簡單的架構其實常常是 same-origin
如果正式環境可以讓:
https://app.example.com/
https://app.example.com/api/...
由同一個 origin 提供 frontend 與 API,很多 CORS 複雜度會直接消失。
不要因為看過微服務架構,就故意把 beginner project 拆成五個 domain。
CORS 不是 CSRF 防護
上一課使用 cookie session。Cookie 的特性之一是 browser 可能會自動把它附在符合條件的 request 上。
CSRF(Cross-Site Request Forgery)問的是另一個問題:
帶著既有登入狀態
對你的網站做一個使用者沒打算做的 state-changing request?
CORS 主要限制 frontend JavaScript 是否能讀 cross-origin response;不能把它當成完整 CSRF 防線。
SameSite 是 defense in depth,不是「設了就結束」
上一課的 session cookie 使用:
SameSite=Lax
它可以降低很多 cross-site cookie request 的風險,但不要把它理解成萬用 CSRF solution。
至少維持兩個基本原則:
GET / HEAD 這類 safe methods 不應該改變重要 server state。
POST / PATCH / DELETE 等 state-changing browser request 要有明確的來源與 CSRF 策略。
正式產品常再搭配 Origin / Referer 驗證、CSRF token 或 framework 內建機制。不同部署模型會影響你選哪種。
這個課程先加入最小 Origin boundary
針對 browser 的 state-changing request,可以先檢查:
function isStateChangingMethod(method) {
return ["POST", "PUT", "PATCH", "DELETE"].includes(method);
}
function isAllowedRequestOrigin(request) {
const origin = request.headers.origin;
if (!origin) {
return true;
}
return origin === appOrigin;
}
在 route logic 前:
if (
isStateChangingMethod(request.method) &&
!isAllowedRequestOrigin(request)
) {
sendError(response, 403, "ORIGIN_NOT_ALLOWED", "Origin not allowed");
return;
}
這是教學用的最小 boundary,不是宣稱完整 CSRF framework。正式系統如果支援更多 browser origin、跨站嵌入、第三方 client 或較高風險操作,要選擇完整 CSRF 策略,而不是只複製這幾行。
Security headers:先加幾個低風險基本值
可以在共用 response helper 中加入:
function applySecurityHeaders(response) {
response.setHeader(
"X-Content-Type-Options",
"nosniff"
);
response.setHeader(
"Referrer-Policy",
"no-referrer"
);
}
Auth / account 相關 response 也可以考慮:
Cache-Control: no-store
但不要把「貼很多 security headers」當成完成安全設計。像 Content-Security-Policy 這類 policy 必須依你的 frontend 資源與部署方式設計,會在真正部署時再看。
Logging 也有邊界
這些東西不要直接 log:
原始 password。
完整 session token。
API key。
完整 Cookie header。
你可以記錄 request method、path、status、耗時、內部 request id;但 log 本身也要被視為敏感資料來源。
做一個最小 request id,讓錯誤比較好追
const { randomUUID } = require("node:crypto");
const requestId = randomUUID();
response.setHeader("X-Request-Id", requestId);
內部 log:
console.error({
requestId,
method: request.method,
path: url.pathname,
error
});
公開 response 不需要把 stack trace 送出去,但可以附 request id 方便查 log。
把 route handler 的順序整理清楚
現在你的 backend 可以依照這個順序思考:
↓
套用基本 response headers
↓
檢查 Origin / CORS / OPTIONS
↓
Route matching
↓
Parse body
↓
Validate / normalize
↓
Authenticate
↓
Authorize
↓
Database / business logic
↓
Stable response / error
不是每個 route 都要重複完整流程,但你的架構應該能清楚表達這些責任。
小挑戰:把 frontend 和 API 故意拆成兩個 origin
讓 backend 保持:
http://localhost:3000
再用另一個 server 開 frontend,例如:
python -m http.server 5500
設定:
APP_ORIGIN=http://localhost:5500
完成以下驗收:
需求 1:frontend 可以用 credentials: "include" 呼叫 /api/me。
需求 2:允許的 origin 可以正常 GET / POST。
需求 3:錯誤 origin 不可以被你的 CORS allowlist 接受。
需求 4:OPTIONS preflight 能回 204。
需求 5:task title 超過 200 字會得到穩定 400 error。
需求 6:故意讓 database operation throw,client 只能看到 generic 500,不會看到 stack / SQL。
需求 7:.env 已在 .gitignore,.env.example 可以 commit。
上 Git 前做一次 secret scan 思維檢查
不需要先裝工具,也可以人工先問:
source code 裡有沒有 sk-...、token、password、database URL?
git diff 裡有沒有不該出去的值?
.env 是否真的未被 Git tracking?
錯誤 response 是否包含內部 path、SQL 或 secret?
frontend JavaScript 是否拿得到本應 server-only 的值?
如果 secret 已經進過 commit,要 rotate,而不是只刪掉目前檔案。
最後收斂:安全不是一個 middleware 名字
Validation → 不信任 client input
Auth → identity / permission 在 backend 驗證
Errors → 對外最小資訊、對內可除錯
CORS → 明確限制 browser cross-origin access
CSRF → cookie auth 的 state-changing request 另外防護
Database → constraints + prepared statements
你現在做的不是「把網站變成絕對安全」,而是把幾個最重要的 trust boundaries 明確化。
下一課會把這些 boundary 帶進 production:GitHub、部署環境、production secrets、database migration、HTTPS 與正式 URL。
完成條件:你能把 secret 與 config 從 source code 拆出來,集中驗證 request input、回傳穩定且不洩漏內部資訊的 errors,並能正確解釋 CORS、credentials、SameSite 與 CSRF 不是同一件事。